1use std::collections::HashSet;
15use std::sync::{Arc, LazyLock, Mutex as StdMutex};
16
17use nostr_sdk::prelude::{Client, Event, Filter, Kind, PublicKey, RelayStatus, RelayUrl, SubscriptionId};
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::state::SessionGuard;
27use crate::ClientRelayExt;
28
29static V2_SUB_ID: LazyLock<Mutex<Option<SubscriptionId>>> = LazyLock::new(|| Mutex::new(None));
31static V2_POOLWIDE_SUB_ID: LazyLock<Mutex<Option<SubscriptionId>>> = LazyLock::new(|| Mutex::new(None));
33static V2_SUB_SET: LazyLock<Mutex<Vec<String>>> = LazyLock::new(|| Mutex::new(Vec::new()));
36
37static V2_SUB_READY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(u64::MAX);
42
43#[inline]
44fn mark_subscription_ready() {
45 V2_SUB_READY_GEN.store(
46 crate::state::SessionGuard::capture().generation(),
47 std::sync::atomic::Ordering::Release,
48 );
49}
50
51pub fn subscription_ready() -> bool {
58 V2_SUB_READY_GEN.load(std::sync::atomic::Ordering::Acquire)
59 == crate::state::SessionGuard::capture().generation()
60}
61static V2_SEEN_WRAPS: LazyLock<Mutex<HashSet<[u8; 32]>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
69const SEEN_WRAPS_CAP: usize = 8192;
71static V2_FOLLOW_TX: LazyLock<StdMutex<Option<UnboundedSender<CommunityId>>>> = LazyLock::new(|| StdMutex::new(None));
81static V2_FOLLOW_PENDING: LazyLock<StdMutex<HashSet<[u8; 32]>>> = LazyLock::new(|| StdMutex::new(HashSet::new()));
83static V2_FOLLOW_LOCKS: LazyLock<StdMutex<std::collections::HashMap<[u8; 32], Arc<Mutex<()>>>>> =
90 LazyLock::new(|| StdMutex::new(std::collections::HashMap::new()));
91
92pub(crate) fn follow_lock(id: &CommunityId) -> Arc<Mutex<()>> {
94 V2_FOLLOW_LOCKS.lock().unwrap().entry(id.0).or_default().clone()
95}
96
97pub async fn subscribed_author_set() -> Vec<String> {
102 V2_SUB_SET.lock().await.clone()
103}
104
105#[cfg(debug_assertions)]
111pub async fn debug_run_follow_stages(id: &CommunityId, session: &SessionGuard) -> (String, String, String) {
112 let lock = follow_lock(id);
113 let _guard = lock.lock().await;
114 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
115
116 let Ok(Some(c)) = crate::db::community::load_community_v2(id) else {
117 return ("community gone".into(), "-".into(), "-".into());
118 };
119 let rekeys = match super::service::follow_rekeys(&transport, &c, session).await {
120 Ok(f) => format!(
121 "Ok(updated={} self_removed={} dissolved={})",
122 f.updated.as_ref().map(|u| format!("root_e{}", u.root_epoch.0)).unwrap_or_else(|| "no".into()),
123 f.self_removed, f.dissolved
124 ),
125 Err(e) => format!("ERR: {e}"),
126 };
127 let Ok(Some(c)) = crate::db::community::load_community_v2(id) else {
128 return (rekeys, "community gone".into(), "-".into());
129 };
130 let control = match super::service::follow_control(&transport, &c, session).await {
131 Ok(v) => format!("Ok(changed={})", v.is_some()),
132 Err(e) => format!("ERR: {e}"),
133 };
134 let Ok(Some(c)) = crate::db::community::load_community_v2(id) else {
135 return (rekeys, control, "community gone".into());
136 };
137 let guestbook = match super::service::sync_guestbook(&transport, &c, session).await {
138 Ok(fresh) => format!("Ok(fresh={})", fresh.len()),
139 Err(e) => format!("ERR: {e}"),
140 };
141 (rekeys, control, guestbook)
142}
143
144pub async fn subscription_id() -> Option<SubscriptionId> {
145 V2_SUB_ID.lock().await.clone()
146}
147
148pub async fn poolwide_subscription_id() -> Option<SubscriptionId> {
149 V2_POOLWIDE_SUB_ID.lock().await.clone()
150}
151
152pub async fn clear() {
155 *V2_SUB_ID.lock().await = None;
156 *V2_POOLWIDE_SUB_ID.lock().await = None;
157 V2_SUB_SET.lock().await.clear();
158 V2_SEEN_WRAPS.lock().await.clear();
159 *V2_FOLLOW_TX.lock().unwrap() = None;
162 V2_FOLLOW_PENDING.lock().unwrap().clear();
163 V2_FOLLOW_LOCKS.lock().unwrap().clear();
164 super::streamauth::clear();
167}
168
169pub fn plane_authors(communities: &[CommunityV2]) -> Vec<PublicKey> {
180 let mut out = Vec::new();
181 for c in communities {
182 out.push(derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).pk());
183 out.push(control_author(c));
184 out.push(super::derive::dissolved_group_key(c.id()).pk());
186 for ch in &c.channels {
187 if ch.private && ch.key.is_none() {
191 continue;
192 }
193 let (secret, epoch) = c.channel_secret(ch);
194 out.push(derive::channel_group_key(&secret, &ch.id, epoch).pk());
195 }
196 out.extend(rekey_authors(c));
197 }
198 out.sort_by_key(|p| p.to_hex());
199 out.dedup();
200 out
201}
202
203pub(crate) fn control_author(c: &CommunityV2) -> PublicKey {
207 derive::control_group_key(&c.community_root, c.id(), c.root_epoch).pk()
208}
209
210pub(crate) fn rekey_authors(c: &CommunityV2) -> Vec<PublicKey> {
217 let mut out = vec![derive::base_rekey_group_key(&c.community_root, c.id(), Epoch(c.root_epoch.0.saturating_add(1))).pk()];
221 for ch in &c.channels {
222 if ch.private {
223 out.push(derive::channel_rekey_group_key(&c.community_root, &ch.id, Epoch(ch.epoch.0.saturating_add(1))).pk());
224 }
225 }
226 out
227}
228
229pub fn load_held_v2() -> Vec<CommunityV2> {
236 let ids = crate::db::community::list_community_ids().unwrap_or_default();
237 ids.iter()
238 .filter(|id| matches!(crate::db::community::community_protocol(id).ok().flatten(), Some(ConcordProtocol::V2)))
239 .filter(|id| !crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&id.0)).unwrap_or(false))
240 .filter_map(|id| crate::db::community::load_community_v2(id).ok().flatten())
241 .collect()
242}
243
244pub async fn refresh_subscription(client: &Client) {
248 {
252 let communities = load_held_v2();
253 let mut relays: Vec<String> = communities.iter().flat_map(|c| c.relays.iter().cloned()).collect();
254 relays.sort();
255 relays.dedup();
256 if !relays.is_empty() {
257 for r in &relays {
259 let _ = client.add_managed_relay(r.as_str()).capabilities(crate::community_relay_capabilities()).await;
260 }
261 client.connect().await;
262 let wanted: Vec<RelayUrl> = relays.iter().filter_map(|r| RelayUrl::parse(r).ok()).collect();
266 for _ in 0..24 {
267 let pool = client.relays().all().await;
268 if wanted.iter().any(|u| pool.get(u).map(|r| r.status() == RelayStatus::Connected).unwrap_or(false)) {
269 break;
270 }
271 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
272 }
273 for c in &communities {
276 super::streamauth::register_community(c);
277 }
278 }
279 }
280
281 let mut sub_guard = V2_SUB_ID.lock().await;
289 let mut set_guard = V2_SUB_SET.lock().await;
290
291 let communities = load_held_v2();
292 let authors = plane_authors(&communities);
293 let mut relays: Vec<String> = communities.iter().flat_map(|c| c.relays.iter().cloned()).collect();
294 relays.sort();
295 relays.dedup();
296
297 let mut new_set: Vec<String> = authors.iter().map(|p| p.to_hex()).collect();
298 new_set.sort();
299
300 if sub_guard.is_some() && *set_guard == new_set && (authors.is_empty() || V2_POOLWIDE_SUB_ID.lock().await.is_some()) {
304 mark_subscription_ready(); return; }
307 if let Some(old) = sub_guard.take() {
308 let _ = client.unsubscribe(&old).await;
309 }
310 *set_guard = new_set;
311
312 if authors.is_empty() {
313 if let Some(old_pw) = V2_POOLWIDE_SUB_ID.lock().await.take() {
314 let _ = client.unsubscribe(&old_pw).await;
315 }
316 mark_subscription_ready();
319 return;
320 }
321
322 let filter = Filter::new()
323 .kinds([Kind::Custom(stream::KIND_WRAP), Kind::Custom(stream::KIND_WRAP_EPHEMERAL)])
324 .authors(authors)
325 .limit(0);
326
327 {
328 let mut pw = V2_POOLWIDE_SUB_ID.lock().await;
329 if let Some(old) = pw.take() {
330 let _ = client.unsubscribe(&old).await;
331 }
332 if let Ok(out) = client.subscribe(filter.clone()).await {
333 *pw = Some(out.value);
334 }
335 }
336 if let Ok(out) = client
337 .subscribe(nostr_sdk::prelude::ReqTarget::manual(
338 relays.iter().cloned().map(|u| (u, vec![filter.clone()])),
339 ))
340 .await
341 {
342 *sub_guard = Some(out.value);
343 }
344 mark_subscription_ready();
345 drop(set_guard);
346 drop(sub_guard);
347
348 prime_auth_in_background(client, relays);
357}
358
359static PRIME_IN_FLIGHT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
363
364fn prime_auth_in_background(client: &Client, relays: Vec<String>) {
365 use std::sync::atomic::Ordering;
366 if relays.is_empty() || PRIME_IN_FLIGHT.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).is_err() {
367 return;
368 }
369 let client = client.clone();
370 let session = crate::state::SessionGuard::capture();
371 tokio::spawn(async move {
372 if session.is_valid() {
373 super::streamauth::prime_auth(&client, &relays).await;
374 }
375 PRIME_IN_FLIGHT.store(false, Ordering::Release);
376 });
377}
378
379pub(crate) async fn resubscribe_relay(client: &Client, relay: &RelayUrl) {
387 let targeted = V2_SUB_ID.lock().await.clone();
388 let poolwide = V2_POOLWIDE_SUB_ID.lock().await.clone();
389 if targeted.is_none() && poolwide.is_none() {
390 return; }
392 let communities = load_held_v2();
393 let authors = plane_authors(&communities);
394 if authors.is_empty() {
395 return;
396 }
397 let filter = Filter::new()
398 .kinds([Kind::Custom(stream::KIND_WRAP), Kind::Custom(stream::KIND_WRAP_EPHEMERAL)])
399 .authors(authors)
400 .limit(0);
401 for id in [targeted, poolwide].into_iter().flatten() {
402 let _ = client
403 .subscribe(nostr_sdk::prelude::ReqTarget::single(relay.clone(), [filter.clone()]))
404 .with_id(id)
405 .await;
406 }
407}
408
409pub async fn dispatch_event(session: &SessionGuard, event: Event, handler: Arc<dyn InboundEventHandler>) {
414 let Some(my_pk) = crate::my_public_key() else {
415 return;
416 };
417 if !session.is_valid() {
418 return;
419 }
420 {
423 let mut seen = V2_SEEN_WRAPS.lock().await;
424 if !seen.insert(event.id.to_bytes()) {
425 return;
426 }
427 if seen.len() > SEEN_WRAPS_CAP {
428 let keep = event.id.to_bytes();
429 seen.clear();
430 seen.insert(keep);
431 }
432 }
433 let communities = load_held_v2();
434 for c in &communities {
435 match inbound::dispatch_wrap(&event, c, &my_pk, &*handler) {
436 inbound::DispatchedV2::NotOurs => continue,
437 inbound::DispatchedV2::Control { .. } | inbound::DispatchedV2::Rekey { .. } => {
442 enqueue_follow(c.id());
443 return;
444 }
445 inbound::DispatchedV2::Dissolved { community_id } => {
446 if crate::db::community::set_community_dissolved(&community_id).unwrap_or(false) {
451 handler.on_community_dissolved(&community_id);
452 if let Some(client) = crate::state::nostr_client() {
453 refresh_subscription(&client).await;
454 }
455 }
456 return;
457 }
458 inbound::DispatchedV2::Chat { channel_id, event } => {
465 if !session.is_valid() {
466 return;
467 }
468 match inbound::persist_chat_event(&event, &channel_id, &my_pk, session).await {
469 Some(inbound::ChatPersist::New(message)) => handler.on_community_message(&channel_id, &message, true),
470 Some(inbound::ChatPersist::Updated { mut message, .. }) => {
475 let _ = crate::db::events::populate_reply_context(&mut message).await;
476 handler.on_community_update(&channel_id, &message.id, &message);
477 }
478 Some(inbound::ChatPersist::ReactionRemoved { mut message, .. }) => {
481 let _ = crate::db::events::populate_reply_context(&mut message).await;
482 handler.on_community_update(&channel_id, &message.id, &message);
483 }
484 Some(inbound::ChatPersist::Removed(target_id)) => handler.on_community_removed(&channel_id, &target_id),
485 None => {}
486 }
487 return;
488 }
489 inbound::DispatchedV2::Presence { .. } => {
490 if !session.is_valid() {
495 return;
496 }
497 let gb = derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch);
498 if let Ok(opened) = super::stream::open_wrap(&event, &gb) {
499 if let Ok(ev) = super::guestbook::parse_guestbook_event(&opened) {
500 let changed = super::service::ingest_guestbook_event(c, ev, event.created_at.as_secs()).unwrap_or(false);
501 if changed && session.is_valid() {
502 handler.on_community_refreshed(&crate::simd::hex::bytes_to_hex_32(&c.id().0));
503 }
504 }
505 }
506 return;
507 }
508 inbound::DispatchedV2::Kick { target } => {
509 if !session.is_valid() {
510 return;
511 }
512 let gb = derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch);
513 let Ok(opened) = super::stream::open_wrap(&event, &gb) else { return };
514 let Ok(ev) = super::guestbook::parse_guestbook_event(&opened) else { return };
515 if !super::service::ingest_guestbook_event(c, ev, event.created_at.as_secs()).unwrap_or(false) {
516 return;
517 }
518 if !session.is_valid() {
519 return;
520 }
521 let community_id = crate::simd::hex::bytes_to_hex_32(&c.id().0);
522 let evicted = my_pk == target && super::service::stored_kick_verdict(c, &my_pk);
534 if evicted {
535 crate::log_warn!(
539 "[v2:teardown {}] KICK: the authorized guestbook fold rules us kicked",
540 &community_id[..8.min(community_id.len())]
541 );
542 handler.on_community_self_removed(&community_id);
543 } else {
544 crate::log_debug!(
545 "[v2:kick {}] declined: target={} is not ruled kicked by the fold",
546 &community_id[..8.min(community_id.len())], &target.to_hex()[..8]
547 );
548 handler.on_community_refreshed(&community_id);
549 }
550 return;
551 }
552 _ => return, }
554 }
555}
556
557pub fn follow_worker_running() -> bool {
561 V2_FOLLOW_TX.lock().unwrap().as_ref().map(|tx| !tx.is_closed()).unwrap_or(false)
562}
563
564pub fn enqueue_follow(id: &CommunityId) {
570 let mut pending = V2_FOLLOW_PENDING.lock().unwrap();
571 if !pending.insert(id.0) {
572 return; }
574 match V2_FOLLOW_TX.lock().unwrap().as_ref() {
575 Some(tx) if tx.send(*id).is_ok() => {}
576 _ => {
577 }
585 }
586}
587
588pub fn spawn_follow_worker(handler: Arc<dyn InboundEventHandler>) {
593 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<CommunityId>();
594 {
599 let pending = V2_FOLLOW_PENDING.lock().unwrap();
600 for id in pending.iter() {
601 let _ = tx.send(CommunityId(*id));
602 }
603 }
604 *V2_FOLLOW_TX.lock().unwrap() = Some(tx);
605 let session = SessionGuard::capture();
606 tokio::spawn(async move {
607 while let Some(id) = rx.recv().await {
608 if !session.is_valid() {
609 break;
610 }
611 V2_FOLLOW_PENDING.lock().unwrap().remove(&id.0);
614 follow_community(&session, &id, &*handler).await;
615 }
616 });
617}
618
619async fn follow_community(session: &SessionGuard, id: &CommunityId, handler: &dyn InboundEventHandler) {
626 let Some(client) = crate::state::nostr_client() else {
627 return;
628 };
629 let lock = follow_lock(id);
632 let _guard = lock.lock().await;
633 let community_id = crate::simd::hex::bytes_to_hex_32(&id.0);
634 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
635
636 let Ok(Some(current)) = crate::db::community::load_community_v2(id) else {
638 return; };
640 match super::service::follow_rekeys(&transport, ¤t, session).await {
641 Ok(follow) if follow.dissolved => {
644 if !session.is_valid() {
645 return;
646 }
647 crate::log_warn!("[v2:teardown {}] DISSOLVED tombstone", &community_id[..8.min(community_id.len())]);
648 handler.on_community_dissolved(&community_id);
649 return;
650 }
651 Ok(follow) if follow.self_removed => {
652 if !session.is_valid() {
653 return;
654 }
655 crate::log_warn!("[v2:teardown {}] REKEY EXCLUSION: an authorized rotation left us out", &community_id[..8.min(community_id.len())]);
656 let _ = crate::db::community::delete_community(&community_id);
657 refresh_subscription(&client).await;
658 handler.on_community_self_removed(&community_id);
659 return;
660 }
661 Ok(follow) if follow.updated.is_some() => {
662 if !session.is_valid() {
663 return;
664 }
665 refresh_subscription(&client).await;
666 handler.on_community_refreshed(&community_id);
667 }
668 Ok(_) => {}
669 Err(e) => {
670 crate::log_warn!("[v2:follow {}] rekey follow failed (will retry on next trigger): {}", &community_id[..8.min(community_id.len())], e);
674 return;
675 }
676 }
677
678 let Ok(Some(current)) = crate::db::community::load_community_v2(id) else {
680 return;
681 };
682 let control_changed = matches!(
683 super::service::follow_control(&transport, ¤t, session).await,
684 Ok(Some(_))
685 );
686 if !session.is_valid() {
687 return;
688 }
689 if let Ok(Some(folded)) = crate::db::community::load_community_v2(id) {
694 let keyed = super::service::absorb_parked_channel_keys(&folded, session);
695 if !keyed.is_empty() {
696 crate::log_info!(
697 "[v2:follow {}] adopted {} vended private-channel key(s)",
698 &community_id[..8.min(community_id.len())],
699 keyed.len()
700 );
701 refresh_subscription(&client).await;
706 }
707 for ch in keyed {
708 let hex = crate::simd::hex::bytes_to_hex_32(&ch.0);
709 let backfilled = crate::VectorCore::v2_backfill_channel(
715 id, &hex, 50, 2, None, None,
716 crate::community::transport::Evidence::Fast, 12,
717 )
718 .await;
719 if !session.is_valid() {
720 return;
721 }
722 handler.on_channel_keyed(&community_id, &hex, backfilled);
723 }
724 }
725 if control_changed {
726 refresh_subscription(&client).await;
727 handler.on_community_refreshed(&community_id);
728 enqueue_follow(id);
734 }
735
736 if let Some(me) = crate::my_public_key() {
745 if crate::db::community::is_author_banned(&community_id, &me) {
746 if !session.is_valid() {
747 return;
748 }
749 crate::log_warn!("[v2:teardown {}] SELF-BAN: our npub is in the folded banlist", &community_id[..8.min(community_id.len())]);
750 handler.on_community_self_removed(&community_id);
751 return;
752 }
753 }
754
755 let Ok(Some(current)) = crate::db::community::load_community_v2(id) else {
761 return;
762 };
763 if let Ok(fresh) = super::service::sync_guestbook(&transport, ¤t, session).await {
764 if fresh.is_empty() || !session.is_valid() {
765 return;
766 }
767 surface_presence(¤t, &fresh, handler);
768 handler.on_community_refreshed(&community_id);
769 }
770}
771
772fn surface_presence(
776 community: &CommunityV2,
777 fresh: &[super::guestbook::GuestbookEvent],
778 handler: &dyn InboundEventHandler,
779) {
780 use super::guestbook::GuestbookEntry;
781 use nostr_sdk::prelude::ToBech32;
782 let Some(primary) = community.primary_channel() else {
783 return;
784 };
785 let chat_id = crate::simd::hex::bytes_to_hex_32(&primary.id.0);
786 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
787 let banned = crate::db::community::banned_set(&cid_hex);
788 for ev in fresh {
789 let (member, joined, at_ms, invited_by) = match &ev.entry {
790 GuestbookEntry::Join { member, at_ms, invited_by } => (member, true, *at_ms, invited_by.clone()),
791 GuestbookEntry::Leave { member, at_ms } => (member, false, *at_ms, None),
792 GuestbookEntry::Kick { .. } | GuestbookEntry::Snapshot { .. } => continue,
793 };
794 if banned.contains(&member.to_bytes()) {
795 continue;
796 }
797 let Ok(npub) = member.to_bech32();
798 let event_id = crate::simd::hex::bytes_to_hex_32(&ev.rumor_id);
799 let (by, label) = match &invited_by {
800 Some((c, l)) => (Some(c.as_str()), Some(l.as_str())),
801 None => (None, None),
802 };
803 handler.on_community_presence(&chat_id, &npub, joined, &event_id, at_ms / 1000, by, label);
804 }
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810 use super::super::control::{genesis, CommunityMetadata};
811 use crate::community::Epoch;
812 use nostr_sdk::prelude::Keys;
813
814 fn a_community(name: &str) -> CommunityV2 {
815 let owner = Keys::generate();
816 let g = genesis(&owner, CommunityMetadata { name: name.into(), ..Default::default() }, 1_000).unwrap();
817 CommunityV2::from_genesis(&g, name, None, vec!["wss://r".into()], 0)
818 }
819
820 #[test]
821 fn subscription_readiness_is_scoped_to_the_session_that_marked_it() {
822 let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
829 mark_subscription_ready();
830 assert!(subscription_ready(), "marked for the current session");
831 crate::state::bump_session_generation();
832 assert!(!subscription_ready(), "an account swap invalidates it");
833 mark_subscription_ready();
834 assert!(subscription_ready(), "the new session's own pass re-arms it");
835 }
836
837 #[test]
838 fn plane_authors_covers_the_dispatched_planes_only() {
839 let c = a_community("A");
840 let authors = plane_authors(std::slice::from_ref(&c));
841
842 let gb = derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).pk();
845 let control = derive::control_group_key(&c.community_root, c.id(), c.root_epoch).pk();
846 let general = {
847 let (s, e) = c.channel_secret(&c.channels[0]);
848 derive::channel_group_key(&s, &c.channels[0].id, e).pk()
849 };
850 let next_base = derive::base_rekey_group_key(&c.community_root, c.id(), Epoch(1)).pk();
853 let dissolved = derive::dissolved_group_key(c.id()).pk();
855 assert!(
856 authors.contains(&gb)
857 && authors.contains(&control)
858 && authors.contains(&general)
859 && authors.contains(&next_base)
860 && authors.contains(&dissolved)
861 );
862 assert_eq!(authors.len(), 5, "guestbook + control + dissolved + chat + base-rekey planes are subscribed");
863 }
864
865 #[test]
866 fn plane_authors_is_deterministic_deduped_and_multi_community() {
867 let a = a_community("A");
868 let b = a_community("B");
869 let one = plane_authors(std::slice::from_ref(&a));
870 assert_eq!(plane_authors(std::slice::from_ref(&a)), one);
872 let two = plane_authors(&[a.clone(), b.clone()]);
874 assert_eq!(two.len(), one.len() * 2);
875 assert_eq!(plane_authors(&[b, a]), two);
877 }
878
879 #[tokio::test]
880 async fn dispatch_event_routes_a_v2_message_to_the_handler() {
881 use crate::community::transport::memory::MemoryRelay;
882 use crate::community::transport::{Query, Transport};
883 use crate::types::Message;
884 use std::sync::Mutex as StdMutex;
885
886 #[derive(Default)]
887 struct Recorder {
888 got: StdMutex<Vec<(String, String)>>,
889 }
890 impl InboundEventHandler for Recorder {
891 fn on_community_message(&self, chat_id: &str, msg: &Message, _new: bool) {
892 self.got.lock().unwrap().push((chat_id.to_string(), msg.content.clone()));
893 }
894 }
895
896 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
898 crate::db::close_database();
899 crate::db::clear_id_caches();
900 let tmp = tempfile::tempdir().unwrap();
901 let acct = {
902 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
903 let mut s = String::from("npub1");
904 for i in 0..58 {
905 s.push(B[(i * 5 + 1) % 32] as char);
906 }
907 s
908 };
909 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
910 crate::db::set_app_data_dir(tmp.path().to_path_buf());
911 crate::db::set_current_account(acct.clone()).unwrap();
912 crate::db::init_database(&acct).unwrap();
913 let _ = crate::state::take_nostr_client();
914 let me = Keys::generate();
915 crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
916 crate::state::set_my_public_key(me.public_key());
917
918 let relay = MemoryRelay::new();
922 let community = super::super::service::create_community(&relay, "Live", vec!["wss://r".into()], None).await.unwrap();
923 let general = community.channels[0].id;
924 let member = Keys::generate();
925 let group = derive::channel_group_key(&community.community_root, &general, community.root_epoch);
926 let rumor = super::super::chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "live ping", None, &[], vec![], 5_000);
927 let (wrap, _) = super::super::chat::seal_chat_rumor(&rumor, &group, &member, nostr_sdk::prelude::Timestamp::from_secs(5), false).unwrap();
928 let _ = relay.publish(&wrap, &community.relays).await;
929 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
930 let wrap = relay.fetch(&q, &community.relays).await.unwrap().into_iter().find(|w| w.pubkey == group.pk()).unwrap();
931
932 let rec = Arc::new(Recorder::default());
938 let session = SessionGuard::capture();
939 crate::community::v2::realtime::clear().await; dispatch_event(&session, wrap.clone(), rec.clone()).await;
941 dispatch_event(&session, wrap, rec.clone()).await;
942
943 let got = rec.got.lock().unwrap();
944 assert_eq!(got.len(), 1, "a re-delivered wrap fires the handler exactly once");
945 assert_eq!(got[0].1, "live ping");
946 assert_eq!(got[0].0, crate::simd::hex::bytes_to_hex_32(&general.0));
947 }
948
949 #[tokio::test]
950 async fn follow_queue_coalesces_a_burst_and_re_enqueues_after_processing() {
951 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<CommunityId>();
954 *V2_FOLLOW_TX.lock().unwrap() = Some(tx);
955 V2_FOLLOW_PENDING.lock().unwrap().clear();
956
957 let id = CommunityId([0x11; 32]);
958 enqueue_follow(&id);
960 enqueue_follow(&id);
961 enqueue_follow(&id);
962 assert_eq!(rx.recv().await, Some(id), "first trigger queues a follow");
963 assert!(rx.try_recv().is_err(), "the burst coalesced to exactly one");
964
965 V2_FOLLOW_PENDING.lock().unwrap().remove(&id.0);
968 enqueue_follow(&id);
969 assert_eq!(rx.recv().await, Some(id), "a trigger after processing re-queues");
970
971 let id2 = CommunityId([0x22; 32]);
973 enqueue_follow(&id2);
974 assert_eq!(rx.recv().await, Some(id2));
975
976 *V2_FOLLOW_TX.lock().unwrap() = None;
977 V2_FOLLOW_PENDING.lock().unwrap().clear();
978 }
979
980 #[tokio::test]
981 async fn a_dissolved_community_honors_no_new_events_and_fires_death_once() {
982 use super::super::service;
983 use crate::community::transport::memory::MemoryRelay;
984 use crate::community::transport::Transport;
985 use crate::types::Message;
986 use std::sync::Mutex as StdMutex;
987
988 #[derive(Default)]
989 struct Recorder {
990 messages: StdMutex<Vec<String>>,
991 deaths: StdMutex<Vec<String>>,
992 }
993 impl InboundEventHandler for Recorder {
994 fn on_community_message(&self, _chat: &str, msg: &Message, _new: bool) {
995 self.messages.lock().unwrap().push(msg.content.clone());
996 }
997 fn on_community_dissolved(&self, community_id: &str) {
998 self.deaths.lock().unwrap().push(community_id.to_string());
999 }
1000 }
1001
1002 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1003 crate::db::close_database();
1004 crate::db::clear_id_caches();
1005 let tmp = tempfile::tempdir().unwrap();
1006 let acct = {
1007 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1008 let mut s = String::from("npub1");
1009 for i in 0..58 {
1010 s.push(B[(i * 3 + 2) % 32] as char);
1011 }
1012 s
1013 };
1014 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
1015 crate::db::set_app_data_dir(tmp.path().to_path_buf());
1016 crate::db::set_current_account(acct.clone()).unwrap();
1017 crate::db::init_database(&acct).unwrap();
1018 let _ = crate::state::take_nostr_client();
1019 let me = Keys::generate();
1020 crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
1021 crate::state::set_my_public_key(me.public_key());
1022
1023 let relay = MemoryRelay::new();
1024 let community = service::create_community(&relay, "Doomed", vec!["wss://r".into()], None).await.unwrap();
1025 let general = community.channels[0].id;
1026
1027 let rumor = super::super::dissolution::dissolved_tombstone_rumor(me.public_key(), community.id(), 8_000);
1032 let tombstone = super::super::dissolution::seal_dissolved(&rumor, community.id(), &me, nostr_sdk::prelude::Timestamp::from_secs(8_000)).unwrap();
1033 let _ = relay.publish(&tombstone, &community.relays).await;
1034 assert!(!crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap(), "not yet locally sealed");
1035
1036 let rec = Arc::new(Recorder::default());
1037 let session = SessionGuard::capture();
1038 clear().await;
1039 dispatch_event(&session, tombstone.clone(), rec.clone()).await;
1042 dispatch_event(&session, tombstone, rec.clone()).await;
1043 assert!(crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap());
1044
1045 let member = Keys::generate();
1049 let cgroup = derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1050 let rumor = super::super::chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "into the grave", None, &[], vec![], 9_000);
1051 let (mw, _) = super::super::chat::seal_chat_rumor(&rumor, &cgroup, &member, nostr_sdk::prelude::Timestamp::from_secs(9), false).unwrap();
1052 dispatch_event(&session, mw, rec.clone()).await;
1053
1054 assert_eq!(rec.deaths.lock().unwrap().len(), 1, "death is announced exactly once");
1055 assert!(rec.messages.lock().unwrap().is_empty(), "a post-tombstone message is never honored (CORD-02 §9)");
1056 }
1057
1058 #[test]
1059 fn a_private_channel_subscribes_to_its_own_chat_plane() {
1060 let mut c = a_community("Priv");
1061 c.channels.push(super::super::community::ChannelV2 {
1062 id: crate::community::ChannelId([0x33; 32]),
1063 name: "mods".into(),
1064 private: true,
1065 key: Some([0x44; 32]),
1066 epoch: Epoch(1),
1067 voice: None,
1068 meta_custom: None,
1069 meta_extra: Default::default(),
1070 });
1071 let authors = plane_authors(std::slice::from_ref(&c));
1072 let priv_chat = derive::channel_group_key(&[0x44; 32], &c.channels[1].id, Epoch(1)).pk();
1074 assert!(authors.contains(&priv_chat), "a private channel subscribes to its own chat plane");
1075 let next_rekey = derive::channel_rekey_group_key(&c.community_root, &c.channels[1].id, Epoch(2)).pk();
1078 assert!(authors.contains(&next_rekey), "a private channel's next rekey plane is subscribed");
1079 }
1080}