vector_core/community/v2/realtime.rs
1//! v2 realtime — the `authors`-based live subscription + dispatch.
2//!
3//! v2 addresses planes by their group pubkey (CORD-01), not v1's `#z` tag, so
4//! the subscription is `{kinds:[1059,21059], authors:[…plane pubkeys…]}`. A
5//! received event is routed to the owning community and handed to the shared
6//! [`inbound::dispatch_wrap`], which fires the protocol-agnostic
7//! `InboundEventHandler` the SDK's `on_message` consumes.
8//!
9//! The kind-1059 dispatch rule (CLAUDE.md A4): the listen loop tries this v2
10//! path for events on the v2 subscription; DM gift wraps and 3313 Direct Invites
11//! (both `#p=me`) stay on the DM subscription — the author-set here never
12//! includes an identity key, so the two never collide.
13
14use std::collections::HashSet;
15use std::sync::{Arc, LazyLock, Mutex as StdMutex};
16
17use nostr_sdk::prelude::{Client, Event, Filter, Kind, PublicKey, RelayStatus, RelayUrl, SubscriptionId, Timestamp};
18use tokio::sync::mpsc::UnboundedSender;
19use tokio::sync::Mutex;
20
21use super::community::CommunityV2;
22use super::stream;
23use super::{derive, inbound};
24use crate::community::{CommunityId, ConcordProtocol, Epoch};
25use crate::event_handler::InboundEventHandler;
26use crate::ClientRelayExt;
27
28/// The targeted subscription id (streams on desktop).
29static V2_SUB_ID: LazyLock<Mutex<Option<SubscriptionId>>> = LazyLock::new(|| Mutex::new(None));
30/// The pool-wide subscription id (the path that streams on Android).
31static V2_POOLWIDE_SUB_ID: LazyLock<Mutex<Option<SubscriptionId>>> = LazyLock::new(|| Mutex::new(None));
32/// The current author-set (sorted hex) — an unchanged set skips a churny
33/// unsubscribe+resubscribe, exactly like v1's `COMMUNITY_SUB_SET`.
34static V2_SUB_SET: LazyLock<Mutex<Vec<String>>> = LazyLock::new(|| Mutex::new(Vec::new()));
35
36/// Session generation of the last COMPLETED [`refresh_subscription`] pass, or
37/// `u64::MAX` before any. Generation-keyed rather than a bare bool so an account
38/// swap invalidates it automatically — a per-account global that survived a swap
39/// would report account A's readiness for account B.
40static V2_SUB_READY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(u64::MAX);
41
42#[inline]
43fn mark_subscription_ready() {
44 V2_SUB_READY_GEN.store(
45 crate::db::current_session_id(),
46 std::sync::atomic::Ordering::Release,
47 );
48}
49
50/// Whether the v2 Community subscription pass has COMPLETED for the active
51/// session: live channel events are being delivered (or there is genuinely
52/// nothing to subscribe to). `false` during the cold-start window where the
53/// relay-connect wait + stream-auth priming are still running — the state a
54/// health-checking bot could previously not distinguish from "subscribed to
55/// nothing".
56pub fn subscription_ready() -> bool {
57 V2_SUB_READY_GEN.load(std::sync::atomic::Ordering::Acquire)
58 == crate::db::current_session_id()
59}
60/// Outer-wrap ids already dispatched, so the handler fires EXACTLY ONCE per
61/// message. The relay pool delivers the same wrap under both the targeted and
62/// pool-wide subs and from every relay independently, so without this a bot's
63/// `on_message` (and its reply) would run several times per message — the v1
64/// community path and the DM path dedup by outer id for the same reason. Cleared
65/// on session swap; coarsely bounded (a message's duplicates all arrive within a
66/// short window, so a recent-set suffices).
67static V2_SEEN_WRAPS: LazyLock<Mutex<HashSet<[u8; 32]>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
68/// Bound on [`V2_SEEN_WRAPS`] before a coarse flush.
69const SEEN_WRAPS_CAP: usize = 8192;
70/// The per-community follow QUEUE. dispatch, boot catch-up, reconnect, and manual
71/// sync all just [`enqueue_follow`] — non-blocking + coalesced. A single spawned
72/// worker ([`spawn_follow_worker`]) drains it and runs one combined rekey+control
73/// follow per community at a time, so two triggers can never concurrently
74/// whole-row-save and clobber each other. This replaces the old gate/rerun/
75/// spawn-vs-await machinery: an enqueue never blocks its caller, and a junk-wrap
76/// flood coalesces to at most one queued + one running follow per community.
77/// Reset on session swap (the worker exits when its `std::sync::Arc<crate::db::Session>` invalidates or
78/// its channel closes).
79static V2_FOLLOW_TX: LazyLock<StdMutex<Option<UnboundedSender<CommunityId>>>> = LazyLock::new(|| StdMutex::new(None));
80/// Community ids currently queued or processing — coalesces a burst to one follow.
81static V2_FOLLOW_PENDING: LazyLock<StdMutex<HashSet<[u8; 32]>>> = LazyLock::new(|| StdMutex::new(HashSet::new()));
82/// Per-community follow serialization, shared by the queue worker AND the inline
83/// (headless) follow path. The worker-vs-inline CHOICE is a benign race
84/// (`follow_worker_running` is check-then-act; a worker can spawn right after a
85/// `false`), so correctness can't ride on it: whichever path runs, the follow body
86/// executes under this lock and two follows of one community can never interleave
87/// their whole-row saves. Bounded by the held-community count; reset on swap.
88static V2_FOLLOW_LOCKS: LazyLock<StdMutex<std::collections::HashMap<[u8; 32], Arc<Mutex<()>>>>> =
89 LazyLock::new(|| StdMutex::new(std::collections::HashMap::new()));
90
91/// The follow lock for one community (created on first use).
92pub(crate) fn follow_lock(id: &CommunityId) -> Arc<Mutex<()>> {
93 V2_FOLLOW_LOCKS.lock().unwrap().entry(id.0).or_default().clone()
94}
95
96/// The author set the live v2 subscription CURRENTLY carries (sorted hex).
97/// Diagnostics: comparing this against the freshly-derived plane authors is the
98/// one-glance test for "did a rotation leave this client subscribed to a dead
99/// epoch" — the failure that otherwise only shows up as silent missing events.
100pub async fn subscribed_author_set() -> Vec<String> {
101 V2_SUB_SET.lock().await.clone()
102}
103
104/// Diagnostics: run the three follow stages once for `id` UNDER the follow lock
105/// (so it can't race the worker into a whole-row clobber) and return each
106/// stage's outcome as a display string. Reloads freshly-persisted state between
107/// stages, exactly like [`follow_community`]. Mutating — same writes a live
108/// follow makes; the lock serializes it against the worker.
109#[cfg(debug_assertions)]
110pub async fn debug_run_follow_stages(id: &CommunityId, session: &std::sync::Arc<crate::db::Session>) -> (String, String, String) {
111 let lock = follow_lock(id);
112 let _guard = lock.lock().await;
113 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
114
115 let Ok(Some(c)) = crate::db::community::load_community_v2(id) else {
116 return ("community gone".into(), "-".into(), "-".into());
117 };
118 let rekeys = match super::service::follow_rekeys(&transport, &c, session).await {
119 Ok(f) => format!(
120 "Ok(updated={} self_removed={} dissolved={})",
121 f.updated.as_ref().map(|u| format!("root_e{}", u.root_epoch.0)).unwrap_or_else(|| "no".into()),
122 f.self_removed, f.dissolved
123 ),
124 Err(e) => format!("ERR: {e}"),
125 };
126 let Ok(Some(c)) = crate::db::community::load_community_v2(id) else {
127 return (rekeys, "community gone".into(), "-".into());
128 };
129 let control = match super::service::follow_control(&transport, &c).await {
130 Ok(v) => format!("Ok(changed={})", v.is_some()),
131 Err(e) => format!("ERR: {e}"),
132 };
133 let Ok(Some(c)) = crate::db::community::load_community_v2(id) else {
134 return (rekeys, control, "community gone".into());
135 };
136 let guestbook = match super::service::sync_guestbook(&transport, &c).await {
137 Ok(fresh) => format!("Ok(fresh={})", fresh.len()),
138 Err(e) => format!("ERR: {e}"),
139 };
140 (rekeys, control, guestbook)
141}
142
143pub async fn subscription_id() -> Option<SubscriptionId> {
144 V2_SUB_ID.lock().await.clone()
145}
146
147pub async fn poolwide_subscription_id() -> Option<SubscriptionId> {
148 V2_POOLWIDE_SUB_ID.lock().await.clone()
149}
150
151/// Clear the v2 realtime state on a session reset (called from `swap_session`
152/// alongside v1's clear), so a stale sub id / author-set can't leak across accounts.
153pub async fn clear() {
154 *V2_SUB_ID.lock().await = None;
155 *V2_POOLWIDE_SUB_ID.lock().await = None;
156 V2_SUB_SET.lock().await.clear();
157 V2_SEEN_WRAPS.lock().await.clear();
158 // Drop the queue sender so the worker's channel closes and it exits; the
159 // next login spawns a fresh worker.
160 *V2_FOLLOW_TX.lock().unwrap() = None;
161 V2_FOLLOW_PENDING.lock().unwrap().clear();
162 V2_FOLLOW_LOCKS.lock().unwrap().clear();
163 // Account A's stream keys must not keep authenticating (or answering relay
164 // challenges) once account B is live.
165 super::streamauth::clear();
166}
167
168/// Every plane pubkey a set of v2 communities publishes under that
169/// [`inbound::dispatch_wrap`] handles — the subscription author-set. Per
170/// community: the guestbook, the control plane, and each channel's current
171/// Chat-Plane address. Pure + deterministic (deduped, sorted) — the testable core.
172///
173/// The **control plane** rides here so a long-running bot follows metadata +
174/// public-channel edits live ([`super::service::follow_control`] re-folds on a
175/// recognized wrap), and the next-epoch rekey planes ride via [`rekey_authors`]
176/// (each subscribed author has its `dispatch_wrap` arm — never one without the
177/// other).
178pub fn plane_authors(communities: &[CommunityV2]) -> Vec<PublicKey> {
179 let mut out = Vec::new();
180 for c in communities {
181 out.push(derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).pk());
182 out.push(control_author(c));
183 // The dissolved plane (CORD-02 §9) — so a mid-session dissolution seals live.
184 out.push(super::derive::dissolved_group_key(c.id()).pk());
185 for ch in &c.channels {
186 // A KEYLESS private channel has no readable chat plane — channel_secret's
187 // root fallback would subscribe the PUBLIC plane for it. Its rekey plane
188 // (below) is still watched, which is how its key arrives.
189 if ch.private && ch.key.is_none() {
190 continue;
191 }
192 let (secret, epoch) = c.channel_secret(ch);
193 out.push(derive::channel_group_key(&secret, &ch.id, epoch).pk());
194 }
195 out.extend(rekey_authors(c));
196 }
197 out.sort_by_key(|p| p.to_hex());
198 out.dedup();
199 out
200}
201
202/// This community's Control Plane address at the current root epoch — the single
203/// source of truth shared by [`plane_authors`] (subscribe) and
204/// [`inbound::dispatch_wrap`] (recognize) so the two can't drift.
205pub(crate) fn control_author(c: &CommunityV2) -> PublicKey {
206 // A split epoch's address is the HELD control_pk (CORD-02 §5); a legacy
207 // epoch derives it from the community_root as always.
208 super::control::ControlPlane::of(c).pk()
209}
210
211/// The next-epoch rekey plane addresses for a community: the base rotation
212/// (`root_epoch + 1`) and each Private channel's rotation (`channel epoch + 1`),
213/// both under the CURRENT community_root. This is the single source of truth for
214/// which rekey wraps we subscribe AND recognize (`inbound::dispatch_wrap` calls
215/// it), so the two can never drift. A Public channel has no independent rotation
216/// (it rides the base), so only Private channels contribute a channel address.
217pub(crate) fn rekey_authors(c: &CommunityV2) -> Vec<PublicKey> {
218 // saturating: a bundle's epoch isn't covered by the community_id commitment, so
219 // it's attacker-influenced — never let `epoch + 1` overflow (a u64::MAX epoch
220 // just yields a dead address, never a panic).
221 let mut out = vec![derive::base_rekey_group_key(&c.community_root, c.id(), Epoch(c.root_epoch.0.saturating_add(1))).pk()];
222 for ch in &c.channels {
223 if ch.private {
224 out.push(derive::channel_rekey_group_key(&c.community_root, &ch.id, Epoch(ch.epoch.0.saturating_add(1))).pk());
225 }
226 }
227 out
228}
229
230/// Load every locally-held, LIVE **v2** community (dispatching each id by its
231/// stored protocol). The realtime layer folds these into the subscription +
232/// routing, so excluding a DISSOLVED community here is what enforces CORD-02 §9
233/// on the receive side: its chat/control/rekey planes stop being subscribed and
234/// an arriving wrap for it is `NotOurs` (dropped, never honored). Held keys still
235/// open old history through the explicit read paths — this only stops NEW events.
236pub fn load_held_v2() -> Vec<CommunityV2> {
237 let ids = crate::db::community::list_community_ids().unwrap_or_default();
238 ids.iter()
239 .filter(|id| matches!(crate::db::community::community_protocol(id).ok().flatten(), Some(ConcordProtocol::V2)))
240 .filter(|id| !crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&id.0)).unwrap_or(false))
241 .filter_map(|id| crate::db::community::load_community_v2(id).ok().flatten())
242 .collect()
243}
244
245/// Refresh the v2 subscription for the held communities: register
246/// `{kinds:[1059,21059], authors:[…]}` on their relays (targeted + pool-wide,
247/// mirroring v1). Idempotent on an unchanged author-set.
248pub async fn refresh_subscription(client: &Client) {
249 // Phase 1, LOCK-FREE: make sure the community relays are added + connected —
250 // the slow part (a connect wait of up to ~6s). Holding the sub locks across
251 // this stalled every concurrent dispatch/refresh behind one caller's connect.
252 {
253 let communities = load_held_v2();
254 let mut relays: Vec<String> = communities.iter().flat_map(|c| c.relays.iter().cloned()).collect();
255 relays.sort();
256 relays.dedup();
257 if !relays.is_empty() {
258 // Community relays ride GOSSIP|PING (warm but excluded from pool-wide DM ops).
259 for r in &relays {
260 let _ = client.add_managed_relay(r.as_str()).capabilities(crate::community_relay_capabilities()).await;
261 }
262 client.connect().await;
263 // Wait briefly for at least one relay to actually connect (a subscribe
264 // against a still-connecting relay silently fails to register — same
265 // trap as v1).
266 let wanted: Vec<RelayUrl> = relays.iter().filter_map(|r| RelayUrl::parse(r).ok()).collect();
267 for _ in 0..24 {
268 let pool = client.relays().all().await;
269 if wanted.iter().any(|u| pool.get(u).map(|r| r.status() == RelayStatus::Connected).unwrap_or(false)) {
270 break;
271 }
272 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
273 }
274 // Register every held plane's key BEFORE subscribing, so the responder
275 // can answer any NIP-42 challenge our REQs trigger. Cheap + local.
276 for c in &communities {
277 super::streamauth::register_community(c);
278 }
279 }
280 }
281
282 // Phase 2, LOCKED + bounded (no connect waits): RE-snapshot the held state
283 // INSIDE the sub locks — the follow worker runs concurrently and may have just
284 // adopted a rotation, so the LAST locker must read the freshest persisted
285 // authors. An out-of-lock read lets a stale caller commit an old author-set
286 // over a fresh one and silently mute a rotated community. (A community whose
287 // relays appeared between the phases subscribes now and connects on the next
288 // refresh — the follow that discovers it always triggers one.)
289 let mut sub_guard = V2_SUB_ID.lock().await;
290 let mut set_guard = V2_SUB_SET.lock().await;
291
292 let communities = load_held_v2();
293 let authors = plane_authors(&communities);
294 let mut relays: Vec<String> = communities.iter().flat_map(|c| c.relays.iter().cloned()).collect();
295 relays.sort();
296 relays.dedup();
297
298 let mut new_set: Vec<String> = authors.iter().map(|p| p.to_hex()).collect();
299 new_set.sort();
300
301 // Unchanged-set fast path — but only when BOTH subs actually registered (a
302 // failed pool-wide subscribe would otherwise stay absent until the author-set
303 // changes, and Android streams via the pool-wide path).
304 if sub_guard.is_some() && *set_guard == new_set && (authors.is_empty() || V2_POOLWIDE_SUB_ID.lock().await.is_some()) {
305 mark_subscription_ready(); // an unchanged live sub is a completed pass
306 return; // the pool re-applies the live subs across reconnects.
307 }
308 if let Some(old) = sub_guard.take() {
309 let _ = client.unsubscribe(&old).await;
310 }
311 *set_guard = new_set;
312
313 if authors.is_empty() {
314 if let Some(old_pw) = V2_POOLWIDE_SUB_ID.lock().await.take() {
315 let _ = client.unsubscribe(&old_pw).await;
316 }
317 // Completed with nothing to hear: READY with zero planes, which is a
318 // different observable state from "still connecting".
319 mark_subscription_ready();
320 return;
321 }
322
323 let filter = Filter::new()
324 .kinds([Kind::Custom(stream::KIND_WRAP), Kind::Custom(stream::KIND_WRAP_EPHEMERAL)])
325 .authors(authors)
326 // limit(0) suppresses the stored dump at REQ time, but a relay that
327 // ACCEPTS a backfilled old event later pushes it to matching live subs
328 // regardless — the `since` floor excludes those at the relay.
329 .since(Timestamp::from_secs(
330 Timestamp::now().as_secs().saturating_sub(crate::community::REALTIME_FRESH_WINDOW_MS / 1000),
331 ))
332 .limit(0);
333
334 {
335 let mut pw = V2_POOLWIDE_SUB_ID.lock().await;
336 if let Some(old) = pw.take() {
337 let _ = client.unsubscribe(&old).await;
338 }
339 if let Ok(out) = client.subscribe(filter.clone()).await {
340 *pw = Some(out.value);
341 }
342 }
343 if let Ok(out) = client
344 .subscribe(nostr_sdk::prelude::ReqTarget::manual(
345 relays.iter().cloned().map(|u| (u, vec![filter.clone()])),
346 ))
347 .await
348 {
349 *sub_guard = Some(out.value);
350 }
351 mark_subscription_ready();
352 drop(set_guard);
353 drop(sub_guard);
354
355 // AUTH-gating relays serve the planes only to a stream-authenticated
356 // connection, and a live subscription isn't auto-retried after the gate. The
357 // priming used to run BEFORE the subscribe, which gated the whole registration
358 // on the slowest relay: a set with dead or auth-refusing members left the
359 // client deaf for a minute while healthy relays sat idle. Prime in the
360 // BACKGROUND instead — healthy relays stream from the registration above, and
361 // each gating relay's auth completion re-sends the subs (`resubscribe_relay`
362 // via the responder), so it joins the moment IT is ready, gating nobody else.
363 prime_auth_in_background(client, relays);
364}
365
366/// In-flight coalescer for the background auth prime: boot fires many refreshes
367/// (dispatch, absorb, reconnect) and each spawning its own prime would stampede
368/// the gating relays with duplicate auth fetches.
369static PRIME_IN_FLIGHT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
370
371fn prime_auth_in_background(client: &Client, relays: Vec<String>) {
372 use std::sync::atomic::Ordering;
373 if relays.is_empty() || PRIME_IN_FLIGHT.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).is_err() {
374 return;
375 }
376 let client = client.clone();
377 crate::db::spawn_bound(async move {
378 super::streamauth::prime_auth(&client, &relays).await;
379 PRIME_IN_FLIGHT.store(false, Ordering::Release);
380 });
381}
382
383/// Re-send the CURRENT v2 subscriptions (same ids) to ONE relay. An AUTH-gating
384/// relay CLOSEs a sub REQ that raced ahead of the stream AUTHs on a fresh
385/// connection, and it never re-challenges once the connection is authenticated —
386/// so the moment the streams finish authenticating is exactly when the subs must
387/// be re-sent. nostr-sdk re-sends them only when ITS OWN auth completes (it
388/// can't see ours), which usually wins by socket ordering; this makes the heal
389/// deterministic and independent of that internal. Same-id REQs are idempotent.
390pub(crate) async fn resubscribe_relay(client: &Client, relay: &RelayUrl) {
391 let targeted = V2_SUB_ID.lock().await.clone();
392 let poolwide = V2_POOLWIDE_SUB_ID.lock().await.clone();
393 if targeted.is_none() && poolwide.is_none() {
394 return; // nothing subscribed yet — the first refresh registers on an authed socket.
395 }
396 let communities = load_held_v2();
397 let authors = plane_authors(&communities);
398 if authors.is_empty() {
399 return;
400 }
401 let filter = Filter::new()
402 .kinds([Kind::Custom(stream::KIND_WRAP), Kind::Custom(stream::KIND_WRAP_EPHEMERAL)])
403 .authors(authors)
404 // limit(0) suppresses the stored dump at REQ time, but a relay that
405 // ACCEPTS a backfilled old event later pushes it to matching live subs
406 // regardless — the `since` floor excludes those at the relay.
407 .since(Timestamp::from_secs(
408 Timestamp::now().as_secs().saturating_sub(crate::community::REALTIME_FRESH_WINDOW_MS / 1000),
409 ))
410 .limit(0);
411 for id in [targeted, poolwide].into_iter().flatten() {
412 let _ = client
413 .subscribe(nostr_sdk::prelude::ReqTarget::single(relay.clone(), [filter.clone()]))
414 .with_id(id)
415 .await;
416 }
417}
418
419/// Route an arriving v2 wrap: find the held community whose plane it opens under
420/// and fire the matching handler callback (via the shared bridge). Persistence to
421/// the local DB is deferred (bots deliver via the callback; GUI history is v1 for
422/// now). `session` gates against a mid-flight account swap.
423pub async fn dispatch_event(event: Event, handler: Arc<dyn InboundEventHandler>) {
424 crate::db::scoped(async move {
425 let Some(my_pk) = crate::my_public_key() else {
426 return;
427 };
428 // Fire EXACTLY ONCE per wrap: the pool re-delivers the same event under both
429 // subs and from every relay. `insert` returns false if already dispatched.
430 {
431 let mut seen = V2_SEEN_WRAPS.lock().await;
432 if !seen.insert(event.id.to_bytes()) {
433 return;
434 }
435 if seen.len() > SEEN_WRAPS_CAP {
436 let keep = event.id.to_bytes();
437 seen.clear();
438 seen.insert(keep);
439 }
440 }
441 let communities = load_held_v2();
442 for c in &communities {
443 match inbound::dispatch_wrap(&event, c, &my_pk, &*handler) {
444 inbound::DispatchedV2::NotOurs => continue,
445 // A control OR a rekey wrap: just enqueue a follow for this community.
446 // Non-blocking + coalesced — the single follow worker serializes control
447 // and rekey per community (no concurrent whole-row clobber) off this hot
448 // path, so a junk-wrap flood can't head-of-line-block the notification loop.
449 inbound::DispatchedV2::Control { .. } | inbound::DispatchedV2::Rekey { .. } => {
450 enqueue_follow(c.id());
451 return;
452 }
453 inbound::DispatchedV2::Dissolved { community_id } => {
454 // Death wins (CORD-02 §9): seal read-only + surface the grave, ONCE
455 // (a re-wrapped tombstone with a fresh outer id must not re-fire the
456 // handler). The next load_held_v2 excludes it, so its planes also
457 // stop being subscribed + routed.
458 if crate::db::community::set_community_dissolved(&community_id).unwrap_or(false) {
459 handler.on_community_dissolved(&community_id);
460 if let Some(client) = crate::state::nostr_client() {
461 refresh_subscription(&client).await;
462 }
463 }
464 return;
465 }
466 // A chat event, opened but NOT yet applied: persist first (dedup by inner
467 // id + the author-scoped edit/delete checks), then fire the callback from
468 // the outcome — v1's exact model. A re-wrapped duplicate (any keyholder
469 // can re-seal a signed rumor into a fresh 1059), the relay echo of our
470 // own send, or a forged edit/delete yields no outcome and re-fires
471 // nothing.
472 inbound::DispatchedV2::Chat { channel_id, event } => {
473 match inbound::persist_chat_event(&event, &channel_id, &my_pk).await {
474 // "New" = first ingest, which a relay-backfill replay of a month-old
475 // event also satisfies — is_new comes from the inner send time so
476 // stale replays persist + surface without ringing anything.
477 Some(inbound::ChatPersist::New(message)) => {
478 let fresh = crate::community::is_realtime_fresh(message.at);
479 handler.on_community_message(&channel_id, &message, fresh);
480 }
481 // A reaction or an edit: the folded TARGET row (its id is the
482 // target's) — the same payload v1 hands this callback. Both come
483 // back out of STATE, whose compact form drops the quoted
484 // attachment's extension, so re-resolve before rendering.
485 Some(inbound::ChatPersist::Updated { mut message, .. }) => {
486 let _ = crate::db::events::populate_reply_context(&mut message).await;
487 handler.on_community_update(&channel_id, &message.id, &message);
488 }
489 // An un-react re-renders the PARENT (its chips changed) — the
490 // same surface a landed reaction drives.
491 Some(inbound::ChatPersist::ReactionRemoved { mut message, .. }) => {
492 let _ = crate::db::events::populate_reply_context(&mut message).await;
493 handler.on_community_update(&channel_id, &message.id, &message);
494 }
495 Some(inbound::ChatPersist::Removed(target_id)) => handler.on_community_removed(&channel_id, &target_id),
496 None => {}
497 }
498 return;
499 }
500 inbound::DispatchedV2::Presence { .. } => {
501 // Live membership motion: fold it into the persisted Guestbook so
502 // the memberlist stays a local read (the presence callback already
503 // fired inline). Reopen here — the dispatcher stays pure — and
504 // refresh the overview when it lands.
505 let gb = derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch);
506 if let Ok(opened) = super::stream::open_wrap(&event, &gb) {
507 if let Ok(ev) = super::guestbook::parse_guestbook_event(&opened) {
508 let changed = super::service::ingest_guestbook_event(c, ev, event.created_at.as_secs()).unwrap_or(false);
509 if changed {
510 handler.on_community_refreshed(&crate::simd::hex::bytes_to_hex_32(&c.id().0));
511 }
512 }
513 }
514 return;
515 }
516 inbound::DispatchedV2::Kick { target } => {
517 let gb = derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch);
518 let Ok(opened) = super::stream::open_wrap(&event, &gb) else { return };
519 let Ok(ev) = super::guestbook::parse_guestbook_event(&opened) else { return };
520 if !super::service::ingest_guestbook_event(c, ev, event.created_at.as_secs()).unwrap_or(false) {
521 return;
522 }
523 let community_id = crate::simd::hex::bytes_to_hex_32(&c.id().0);
524 // Self-removal rides the AUTHORIZED fold, never the wrap: the store takes
525 // any Kick rumor, so acting on arrival would let any member evict anyone.
526 // Fail-closed — a kick our roster can't justify leaves us in place.
527 //
528 // The COALESCE verdict, not the memberlist: on a rejoin the store starts
529 // empty while the control fold has already re-derived our old ban mark, so
530 // the memberlist legitimately excludes us for the window before the
531 // Guestbook catches up. A stale Kick landing in that window would read as
532 // an authorized eviction. Coalescing asks only what our latest authorized
533 // entry is, so a fresh Join beats the old Kick and an empty store decides
534 // nothing.
535 let evicted = my_pk == target && super::service::stored_kick_verdict(c, &my_pk);
536 if evicted {
537 // WARN, not info: `log_info!` is compiled out in release, and a
538 // self-eviction is exactly the destructive, hard-to-diagnose event
539 // that must leave a trace on a shipped build.
540 crate::log_warn!(
541 "[v2:teardown {}] KICK: the authorized guestbook fold rules us kicked",
542 &community_id[..8.min(community_id.len())]
543 );
544 handler.on_community_self_removed(&community_id);
545 } else {
546 crate::log_debug!(
547 "[v2:kick {}] declined: target={} is not ruled kicked by the fold",
548 &community_id[..8.min(community_id.len())], &target.to_hex()[..8]
549 );
550 handler.on_community_refreshed(&community_id);
551 }
552 return;
553 }
554 _ => return, // typing (and non-surfaced guestbook kinds) handled inline by the dispatcher.
555 }
556 }
557 })
558 .await
559}
560
561/// Whether a live follow worker is draining the queue (a `listen()` is running).
562/// Headless callers use this to run a follow inline instead of enqueueing into
563/// the void.
564pub fn follow_worker_running() -> bool {
565 V2_FOLLOW_TX.lock().unwrap().as_ref().map(|tx| !tx.is_closed()).unwrap_or(false)
566}
567
568/// Queue a follow for `id` — NON-BLOCKING + coalesced. A burst (or a junk-wrap
569/// flood) collapses to at most one queued + one running follow per community. A
570/// no-op if no worker is running (no live `listen()`). Callers: dispatch,
571/// boot/reconnect catch-up, manual sync — none of them block or touch a lock for
572/// longer than the enqueue.
573pub fn enqueue_follow(id: &CommunityId) {
574 let mut pending = V2_FOLLOW_PENDING.lock().unwrap();
575 if !pending.insert(id.0) {
576 return; // already queued or processing — coalesce.
577 }
578 match V2_FOLLOW_TX.lock().unwrap().as_ref() {
579 Some(tx) if tx.send(*id).is_ok() => {}
580 _ => {
581 // No worker yet — PARK the id in the pending set instead of
582 // dropping it; spawn_follow_worker drains parked ids into its
583 // fresh queue. The boot sweep's enqueues race notifs' worker
584 // spawn (they fire the moment init completes), so a pre-worker
585 // enqueue must defer, never silently vanish — a dropped boot
586 // refold leaves an offline-rotated community wedged at its old
587 // epoch until some live event happens to trigger a dispatch.
588 }
589 }
590}
591
592/// Spawn the single follow worker for this session. Installs the queue sender and
593/// drains it, running one combined follow per community at a time. Replacing the
594/// sender (a re-`listen()`) or [`clear`] (a swap) closes the old channel so the old
595/// worker exits; the captured `std::sync::Arc<crate::db::Session>` also stops it. Idempotent per session.
596pub fn spawn_follow_worker(handler: Arc<dyn InboundEventHandler>) {
597 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<CommunityId>();
598 // Re-send every pending id into the fresh queue: parked pre-worker
599 // enqueues AND anything a replaced worker's dropped channel still owed.
600 // Entries stay in the set — the worker removes each as it starts
601 // processing, preserving the coalescing invariant.
602 {
603 let pending = V2_FOLLOW_PENDING.lock().unwrap();
604 for id in pending.iter() {
605 let _ = tx.send(CommunityId(*id));
606 }
607 }
608 *V2_FOLLOW_TX.lock().unwrap() = Some(tx);
609 let session = crate::db::current_session();
610 crate::db::spawn_bound(async move {
611 while let Some(id) = rx.recv().await {
612 // Remove from pending BEFORE running, so a trigger arriving DURING the
613 // follow re-enqueues (and is processed after) rather than being lost.
614 V2_FOLLOW_PENDING.lock().unwrap().remove(&id.0);
615 follow_community(&session, &id, &*handler).await;
616 }
617 });
618}
619
620/// One combined rekey-then-control follow for a community, each pass against the
621/// FRESHLY-RELOADED persisted state (never a stale clone, so the two planes can't
622/// lose each other's writes). Rekey runs first: a base adopt moves the control
623/// address, and a self-removal tears the community down (skipping control). No-op
624/// without a live client — unit tests drive `service::follow_control` /
625/// `follow_rekeys` directly.
626async fn follow_community(session: &std::sync::Arc<crate::db::Session>, id: &CommunityId, handler: &dyn InboundEventHandler) {
627 crate::db::scoped(async move {
628 let Some(client) = crate::state::nostr_client() else {
629 return;
630 };
631 // Serialize against an inline (headless) follow of the same community — the
632 // queue only serializes triggers routed THROUGH it.
633 let lock = follow_lock(id);
634 let _guard = lock.lock().await;
635 let community_id = crate::simd::hex::bytes_to_hex_32(&id.0);
636 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
637
638 // Rekey first (fresh DB state).
639 let Ok(Some(current)) = crate::db::community::load_community_v2(id) else {
640 return; // community gone (left / removed).
641 };
642 match super::service::follow_rekeys(&transport, ¤t, session).await {
643 // A tombstone surfaced during catch-up (an offline member learning of a
644 // death) — the flag is set; seal + surface, and stop following.
645 Ok(follow) if follow.dissolved => {
646 crate::log_warn!("[v2:teardown {}] DISSOLVED tombstone", &community_id[..8.min(community_id.len())]);
647 handler.on_community_dissolved(&community_id);
648 return;
649 }
650 Ok(follow) if follow.self_removed => {
651 crate::log_warn!("[v2:teardown {}] REKEY EXCLUSION: an authorized rotation left us out", &community_id[..8.min(community_id.len())]);
652 let _ = crate::db::community::delete_community(&community_id);
653 refresh_subscription(&client).await;
654 handler.on_community_self_removed(&community_id);
655 return;
656 }
657 Ok(follow) if follow.updated.is_some() => {
658 refresh_subscription(&client).await;
659 handler.on_community_refreshed(&community_id);
660 }
661 Ok(_) => {}
662 Err(e) => {
663 // Surfaced, not swallowed: with EOSE-verified fetches an AUTH-gated
664 // or dead rekey plane now reports here instead of masquerading as
665 // "no rotation" — the exact signature of an epoch wedge.
666 crate::log_warn!("[v2:follow {}] rekey follow failed (will retry on next trigger): {}", &community_id[..8.min(community_id.len())], e);
667 return;
668 }
669 }
670
671 // Control second, on the (possibly new-root) freshly-reloaded state.
672 let Ok(Some(current)) = crate::db::community::load_community_v2(id) else {
673 return;
674 };
675 let control_changed = matches!(
676 super::service::follow_control(&transport, ¤t).await,
677 Ok(Some(_))
678 );
679 // Re-judge parked key vends on EVERY pass, not only when the fold moved. A
680 // vend that lands AFTER its Grant folded has nothing left to change the
681 // control plane, so gating this on `changed` strands it until some unrelated
682 // edition happens by (CORD-03 "delivered on grant").
683 if let Ok(Some(folded)) = crate::db::community::load_community_v2(id) {
684 let keyed = super::service::absorb_parked_channel_keys(&folded, session);
685 if !keyed.is_empty() {
686 crate::log_info!(
687 "[v2:follow {}] adopted {} vended private-channel key(s)",
688 &community_id[..8.min(community_id.len())],
689 keyed.len()
690 );
691 // A newly-keyed channel adds its chat plane to the readable set, and
692 // the live subscription is addressed BY that set — without a rebuild
693 // we hold the key and still never hear the channel. Independent of
694 // `control_changed`: adopting a parked vend changes no control state.
695 refresh_subscription(&client).await;
696 }
697 for ch in keyed {
698 let hex = crate::simd::hex::bytes_to_hex_32(&ch.0);
699 // Read what was said while we were locked out. The live subscription
700 // only carries what arrives NEXT, so without this a member granted
701 // access walks into a room that looks empty. The count rides the hook:
702 // backfilled history is NOT dispatched as live messages, and without
703 // the number a handler cannot even tell there is history to go read.
704 let backfilled = crate::VectorCore::v2_backfill_channel(
705 id, &hex, 50, 2, None, None,
706 crate::community::transport::Evidence::Fast, 12,
707 )
708 .await;
709 handler.on_channel_keyed(&community_id, &hex, backfilled);
710 }
711 }
712 if control_changed {
713 refresh_subscription(&client).await;
714 handler.on_community_refreshed(&community_id);
715 // A control change can reveal rekey work that predates it — a just-announced
716 // private channel's key crate is already sitting on its rekey plane (the key
717 // ships BEFORE the vsk-2), and this pass's rekey walk ran before the channel
718 // existed. Queue one more pass; it coalesces and converges (an unchanged
719 // control fold doesn't re-queue).
720 enqueue_follow(id);
721 }
722
723 // Banned by the banlist we just folded → self-remove NOW, not when the Refounding
724 // eventually lands. A Ban composes three layers (CORD-04 §6) and their guarantees
725 // arrive at different speeds: the Banlist edition is instant, the Refounding is
726 // "heavy and asynchronous" by design. Waiting on the rotation to notice our own
727 // removal left the target sitting in a community that had already dropped them from
728 // its member count — silenced, still looking joined. Checked unconditionally: a
729 // banlist-only change folds no document, so `follow_control` returns None for it.
730 // v1 has done this since it shipped (`am_i_banned` in its own realtime refresh).
731 if let Some(me) = crate::my_public_key() {
732 if crate::db::community::is_author_banned(&community_id, &me) {
733 crate::log_warn!("[v2:teardown {}] SELF-BAN: our npub is in the folded banlist", &community_id[..8.min(community_id.len())]);
734 handler.on_community_self_removed(&community_id);
735 return;
736 }
737 }
738
739 // Guestbook third: catch the membership store up from its cursor. Boot and
740 // reconnect land here through this same queue, so the memberlist is a local
741 // read by the time any panel asks — and every join/leave the catch-up folds
742 // surfaces as a presence line (real-rumor-id keyed, so a line each path also
743 // saw live inserts exactly once).
744 let Ok(Some(current)) = crate::db::community::load_community_v2(id) else {
745 return;
746 };
747 if let Ok(fresh) = super::service::sync_guestbook(&transport, ¤t).await {
748 if fresh.is_empty() {
749 return;
750 }
751 surface_presence(¤t, &fresh, handler);
752 handler.on_community_refreshed(&community_id);
753 }
754 })
755 .await
756}
757
758/// Fire the presence-line surface for freshly-folded guestbook events — the
759/// catch-up twin of the live dispatch (same handler, same real-id dedup key,
760/// same banned-author drop). Kicks/snapshots shape the memberlist, not the feed.
761fn surface_presence(
762 community: &CommunityV2,
763 fresh: &[super::guestbook::GuestbookEvent],
764 handler: &dyn InboundEventHandler,
765) {
766 use super::guestbook::GuestbookEntry;
767 use nostr_sdk::prelude::ToBech32;
768 let Some(primary) = community.primary_channel() else {
769 return;
770 };
771 let chat_id = crate::simd::hex::bytes_to_hex_32(&primary.id.0);
772 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
773 let banned = crate::db::community::banned_set(&cid_hex);
774 for ev in fresh {
775 let (member, joined, at_ms, invited_by) = match &ev.entry {
776 GuestbookEntry::Join { member, at_ms, invited_by } => (member, true, *at_ms, invited_by.clone()),
777 GuestbookEntry::Leave { member, at_ms } => (member, false, *at_ms, None),
778 GuestbookEntry::Kick { .. } | GuestbookEntry::Snapshot { .. } => continue,
779 };
780 if banned.contains(&member.to_bytes()) {
781 continue;
782 }
783 let Ok(npub) = member.to_bech32();
784 let event_id = crate::simd::hex::bytes_to_hex_32(&ev.rumor_id);
785 let (by, label) = match &invited_by {
786 Some((c, l)) => (Some(c.as_str()), Some(l.as_str())),
787 None => (None, None),
788 };
789 handler.on_community_presence(&chat_id, &npub, joined, &event_id, at_ms / 1000, by, label);
790 }
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796 use super::super::control::{genesis, CommunityMetadata};
797 use crate::community::Epoch;
798 use nostr_sdk::prelude::Keys;
799
800 fn a_community(name: &str) -> CommunityV2 {
801 let owner = Keys::generate();
802 let g = genesis(&owner, CommunityMetadata { name: name.into(), ..Default::default() }, 1_000).unwrap();
803 CommunityV2::from_genesis(&g, name, None, vec!["wss://r".into()], 0)
804 }
805
806 #[test]
807 fn subscription_readiness_is_scoped_to_the_session_that_marked_it() {
808 // Generation-keyed on purpose: a bare bool would survive an account swap
809 // and report account A's readiness for account B — the exact class of
810 // per-account-global bug the swap-cache sweep exists to prevent.
811 //
812 // Bumping the GLOBAL generation invalidates every live std::sync::Arc<crate::db::Session>, so
813 // serialize with the swap-sensitive tests via the same guard they hold.
814 let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
815 mark_subscription_ready();
816 assert!(subscription_ready(), "marked for the current session");
817 crate::db::close_database();
818 assert!(!subscription_ready(), "an account swap invalidates it");
819 mark_subscription_ready();
820 assert!(subscription_ready(), "the new session's own pass re-arms it");
821 }
822
823 #[test]
824 fn plane_authors_covers_the_dispatched_planes_only() {
825 let c = a_community("A");
826 let authors = plane_authors(std::slice::from_ref(&c));
827
828 // Subscribed: the guestbook, the control plane, and the one public channel
829 // (the planes dispatch_wrap handles). Exactly those three.
830 let gb = derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).pk();
831 // The split address (the held control_pk), not the legacy derivation.
832 let control = crate::community::v2::control::ControlPlane::of(&c).pk();
833 assert_ne!(control, derive::control_group_key(&c.community_root, c.id(), c.root_epoch).pk());
834 let general = {
835 let (s, e) = c.channel_secret(&c.channels[0]);
836 derive::channel_group_key(&s, &c.channels[0].id, e).pk()
837 };
838 // Plus the next base-rekey address (rekey-follow). A public channel has no
839 // independent rotation, so #general contributes no channel-rekey address.
840 let next_base = derive::base_rekey_group_key(&c.community_root, c.id(), Epoch(1)).pk();
841 // Plus the dissolved plane (CORD-02 §9), so a live dissolution is detected.
842 let dissolved = derive::dissolved_group_key(c.id()).pk();
843 assert!(
844 authors.contains(&gb)
845 && authors.contains(&control)
846 && authors.contains(&general)
847 && authors.contains(&next_base)
848 && authors.contains(&dissolved)
849 );
850 assert_eq!(authors.len(), 5, "guestbook + control + dissolved + chat + base-rekey planes are subscribed");
851 }
852
853 #[test]
854 fn plane_authors_is_deterministic_deduped_and_multi_community() {
855 let a = a_community("A");
856 let b = a_community("B");
857 let one = plane_authors(std::slice::from_ref(&a));
858 // Re-running over the same community is byte-identical (deterministic).
859 assert_eq!(plane_authors(std::slice::from_ref(&a)), one);
860 // Two distinct communities' planes are all present, none dropped.
861 let two = plane_authors(&[a.clone(), b.clone()]);
862 assert_eq!(two.len(), one.len() * 2);
863 // Order-independent: reversing the input yields the identical sorted set.
864 assert_eq!(plane_authors(&[b, a]), two);
865 }
866
867 #[tokio::test]
868 async fn dispatch_event_routes_a_v2_message_to_the_handler() {
869 use crate::community::transport::memory::MemoryRelay;
870 use crate::community::transport::{Query, Transport};
871 use crate::types::Message;
872 use std::sync::Mutex as StdMutex;
873
874 #[derive(Default)]
875 struct Recorder {
876 got: StdMutex<Vec<(String, String)>>,
877 }
878 impl InboundEventHandler for Recorder {
879 fn on_community_message(&self, chat_id: &str, msg: &Message, _new: bool) {
880 self.got.lock().unwrap().push((chat_id.to_string(), msg.content.clone()));
881 }
882 }
883
884 // Offline DB + identity.
885 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
886 crate::db::close_database();
887 crate::db::clear_id_caches();
888 let tmp = tempfile::tempdir().unwrap();
889 let acct = {
890 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
891 let mut s = String::from("npub1");
892 for i in 0..58 {
893 s.push(B[(i * 5 + 1) % 32] as char);
894 }
895 s
896 };
897 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
898 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
899 crate::db::set_current_account(acct.clone()).unwrap();
900 crate::db::init_database(&acct).unwrap();
901 let _ = crate::state::take_nostr_client();
902 let me = Keys::generate();
903 crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
904 crate::state::set_my_public_key(me.public_key());
905
906 // Create a v2 community (persisted), then ANOTHER member (holds the root)
907 // posts — the incoming case a live sub delivers (an OWN send is echoed at
908 // send time, so its relay copy correctly dedups instead of firing).
909 let relay = MemoryRelay::new();
910 let community = super::super::service::create_community(&relay, "Live", vec!["wss://r".into()], None).await.unwrap();
911 let general = community.channels[0].id;
912 let member = Keys::generate();
913 let group = derive::channel_group_key(&community.community_root, &general, community.root_epoch);
914 let rumor = super::super::chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "live ping", None, &[], vec![], 5_000);
915 let (wrap, _) = super::super::chat::seal_chat_rumor(&rumor, &group, &member, nostr_sdk::prelude::Timestamp::from_secs(5), false).unwrap();
916 let _ = relay.publish(&wrap, &community.relays).await;
917 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
918 let wrap = relay.fetch(&q, &community.relays).await.unwrap().into_iter().find(|w| w.pubkey == group.pk()).unwrap();
919
920 // The realtime dispatch (loading held v2 communities from the DB) routes it.
921 // Dispatch the SAME wrap TWICE — modelling the pool re-delivering it under
922 // the targeted + pool-wide subs (and from multiple relays). The handler
923 // must fire EXACTLY ONCE (no duplicate bot replies) — and only AFTER the
924 // persist outcome (the callbacks-from-persist model).
925 let rec = Arc::new(Recorder::default());
926 crate::community::v2::realtime::clear().await; // fresh seen-set for the test
927 dispatch_event(wrap.clone(), rec.clone()).await;
928 dispatch_event(wrap, rec.clone()).await;
929
930 let got = rec.got.lock().unwrap();
931 assert_eq!(got.len(), 1, "a re-delivered wrap fires the handler exactly once");
932 assert_eq!(got[0].1, "live ping");
933 assert_eq!(got[0].0, crate::simd::hex::bytes_to_hex_32(&general.0));
934 }
935
936 #[tokio::test]
937 async fn follow_queue_coalesces_a_burst_and_re_enqueues_after_processing() {
938 // Install a test channel in place of the worker's, so we can observe what the
939 // queue delivers without a live client.
940 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<CommunityId>();
941 *V2_FOLLOW_TX.lock().unwrap() = Some(tx);
942 V2_FOLLOW_PENDING.lock().unwrap().clear();
943
944 let id = CommunityId([0x11; 32]);
945 // A burst for one community collapses to a SINGLE queued follow (coalesced).
946 enqueue_follow(&id);
947 enqueue_follow(&id);
948 enqueue_follow(&id);
949 assert_eq!(rx.recv().await, Some(id), "first trigger queues a follow");
950 assert!(rx.try_recv().is_err(), "the burst coalesced to exactly one");
951
952 // The worker removes it from pending before running; a trigger AFTER that
953 // re-queues (so a change during a follow isn't lost).
954 V2_FOLLOW_PENDING.lock().unwrap().remove(&id.0);
955 enqueue_follow(&id);
956 assert_eq!(rx.recv().await, Some(id), "a trigger after processing re-queues");
957
958 // A different community is independent (not coalesced against the first).
959 let id2 = CommunityId([0x22; 32]);
960 enqueue_follow(&id2);
961 assert_eq!(rx.recv().await, Some(id2));
962
963 *V2_FOLLOW_TX.lock().unwrap() = None;
964 V2_FOLLOW_PENDING.lock().unwrap().clear();
965 }
966
967 #[tokio::test]
968 async fn a_dissolved_community_honors_no_new_events_and_fires_death_once() {
969 use super::super::service;
970 use crate::community::transport::memory::MemoryRelay;
971 use crate::community::transport::Transport;
972 use crate::types::Message;
973 use std::sync::Mutex as StdMutex;
974
975 #[derive(Default)]
976 struct Recorder {
977 messages: StdMutex<Vec<String>>,
978 deaths: StdMutex<Vec<String>>,
979 }
980 impl InboundEventHandler for Recorder {
981 fn on_community_message(&self, _chat: &str, msg: &Message, _new: bool) {
982 self.messages.lock().unwrap().push(msg.content.clone());
983 }
984 fn on_community_dissolved(&self, community_id: &str) {
985 self.deaths.lock().unwrap().push(community_id.to_string());
986 }
987 }
988
989 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
990 crate::db::close_database();
991 crate::db::clear_id_caches();
992 let tmp = tempfile::tempdir().unwrap();
993 let acct = {
994 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
995 let mut s = String::from("npub1");
996 for i in 0..58 {
997 s.push(B[(i * 3 + 2) % 32] as char);
998 }
999 s
1000 };
1001 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
1002 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
1003 crate::db::set_current_account(acct.clone()).unwrap();
1004 crate::db::init_database(&acct).unwrap();
1005 let _ = crate::state::take_nostr_client();
1006 let me = Keys::generate();
1007 crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
1008 crate::state::set_my_public_key(me.public_key());
1009
1010 let relay = MemoryRelay::new();
1011 let community = service::create_community(&relay, "Doomed", vec!["wss://r".into()], None).await.unwrap();
1012 let general = community.channels[0].id;
1013
1014 // The owner's tombstone arrives as a MEMBER sees it (local flag still 0 —
1015 // build + publish it directly rather than via dissolve_community, which
1016 // would seal our own DB first and make it the owner-published case). `me`
1017 // is the owner, so the seal verifies.
1018 let rumor = super::super::dissolution::dissolved_tombstone_rumor(me.public_key(), community.id(), 8_000);
1019 let tombstone = super::super::dissolution::seal_dissolved(&rumor, community.id(), &me, nostr_sdk::prelude::Timestamp::from_secs(8_000)).unwrap();
1020 let _ = relay.publish(&tombstone, &community.relays).await;
1021 assert!(!crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap(), "not yet locally sealed");
1022
1023 let rec = Arc::new(Recorder::default());
1024 clear().await;
1025 // Fire the SAME tombstone twice AND a fresh re-wrap of its verified seal
1026 // (distinct outer id) — death must be announced exactly once.
1027 dispatch_event(tombstone.clone(), rec.clone()).await;
1028 dispatch_event(tombstone, rec.clone()).await;
1029 assert!(crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap());
1030
1031 // A member posts a fresh message to #general AFTER the tombstone. It must
1032 // not be honored (the community is excluded from load_held_v2, so its
1033 // plane is NotOurs).
1034 let member = Keys::generate();
1035 let cgroup = derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1036 let rumor = super::super::chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "into the grave", None, &[], vec![], 9_000);
1037 let (mw, _) = super::super::chat::seal_chat_rumor(&rumor, &cgroup, &member, nostr_sdk::prelude::Timestamp::from_secs(9), false).unwrap();
1038 dispatch_event(mw, rec.clone()).await;
1039
1040 assert_eq!(rec.deaths.lock().unwrap().len(), 1, "death is announced exactly once");
1041 assert!(rec.messages.lock().unwrap().is_empty(), "a post-tombstone message is never honored (CORD-02 §9)");
1042 }
1043
1044 #[test]
1045 fn a_private_channel_subscribes_to_its_own_chat_plane() {
1046 let mut c = a_community("Priv");
1047 c.channels.push(super::super::community::ChannelV2 {
1048 id: crate::community::ChannelId([0x33; 32]),
1049 name: "mods".into(),
1050 private: true,
1051 key: Some([0x44; 32]),
1052 epoch: Epoch(1),
1053 voice: None,
1054 meta_custom: None,
1055 meta_extra: Default::default(),
1056 });
1057 let authors = plane_authors(std::slice::from_ref(&c));
1058 // A private channel is read under its OWN key/epoch (not the root).
1059 let priv_chat = derive::channel_group_key(&[0x44; 32], &c.channels[1].id, Epoch(1)).pk();
1060 assert!(authors.contains(&priv_chat), "a private channel subscribes to its own chat plane");
1061 // Its next-rekey address IS subscribed (rekey-follow), keyed by the current
1062 // root at the channel's next epoch — so a rotation is delivered.
1063 let next_rekey = derive::channel_rekey_group_key(&c.community_root, &c.channels[1].id, Epoch(2)).pk();
1064 assert!(authors.contains(&next_rekey), "a private channel's next rekey plane is subscribed");
1065 }
1066}