From 1f350f511e9a67866e684d2190cf033f41d5be8c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 1 Oct 2026 04:23:18 +0000 Subject: [PATCH] feat: add support for NIP-29 (#56) Reviewed-on: https://git.reya.info/reya/coop/pulls/56 --- Cargo.lock | 81 +- Cargo.toml | 2 +- assets/icons/ban.svg | 1 + assets/icons/edit.svg | 1 + assets/icons/login.svg | 1 + assets/icons/logout.svg | 1 + assets/icons/upload.svg | 4 +- crates/chat_ui/src/lib.rs | 2 +- crates/community/src/cache.rs | 331 ---- crates/community/src/community.rs | 1258 ------------- crates/community/src/history.rs | 764 -------- crates/community/src/lib.rs | 829 -------- crates/community/src/rekey.rs | 1211 ------------ crates/community/src/sync.rs | 1660 ----------------- crates/community_ui/src/lib.rs | 832 --------- crates/community_ui/src/message.rs | 77 - crates/{community => nip29}/Cargo.toml | 19 +- crates/nip29/src/group.rs | 748 ++++++++ crates/nip29/src/lib.rs | 909 +++++++++ crates/nip29/src/protocol.rs | 925 +++++++++ crates/{community_ui => nip29_ui}/Cargo.toml | 14 +- crates/nip29_ui/src/details.rs | 268 +++ crates/nip29_ui/src/invite.rs | 112 ++ crates/nip29_ui/src/lib.rs | 1065 +++++++++++ crates/nip29_ui/src/message.rs | 239 +++ crates/nip29_ui/src/metadata.rs | 169 ++ crates/settings/src/lib.rs | 18 - crates/theme/src/colors.rs | 4 +- crates/ui/src/dock/panel.rs | 12 +- crates/ui/src/icon.rs | 8 + crates/ui/src/menu/dropdown_menu.rs | 3 + crates/ui/src/nav.rs | 31 +- crates/workspace/Cargo.toml | 4 +- crates/workspace/src/dialogs/join_group.rs | 35 + crates/workspace/src/dialogs/mod.rs | 4 +- crates/workspace/src/dialogs/new_community.rs | 36 - crates/workspace/src/dialogs/new_group.rs | 106 ++ crates/workspace/src/dialogs/profile.rs | 166 ++ crates/workspace/src/lib.rs | 70 +- crates/workspace/src/panels/browse.rs | 280 ++- crates/workspace/src/sidebar/mod.rs | 628 ++++--- crates/workspace/src/sidebar/tab.rs | 12 +- crates/workspace/src/sidebar/utils.rs | 14 +- desktop/Cargo.toml | 2 +- desktop/src/main.rs | 4 +- docs/concord-usage.md | 780 -------- docs/nip29-migration-plan.md | 384 ++++ docs/nip29-usage.md | 625 +++++++ web/Cargo.toml | 2 +- web/src/lib.rs | 4 +- 50 files changed, 6574 insertions(+), 8181 deletions(-) create mode 100644 assets/icons/ban.svg create mode 100644 assets/icons/edit.svg create mode 100644 assets/icons/login.svg create mode 100644 assets/icons/logout.svg delete mode 100644 crates/community/src/cache.rs delete mode 100644 crates/community/src/community.rs delete mode 100644 crates/community/src/history.rs delete mode 100644 crates/community/src/lib.rs delete mode 100644 crates/community/src/rekey.rs delete mode 100644 crates/community/src/sync.rs delete mode 100644 crates/community_ui/src/lib.rs delete mode 100644 crates/community_ui/src/message.rs rename crates/{community => nip29}/Cargo.toml (72%) create mode 100644 crates/nip29/src/group.rs create mode 100644 crates/nip29/src/lib.rs create mode 100644 crates/nip29/src/protocol.rs rename crates/{community_ui => nip29_ui}/Cargo.toml (71%) create mode 100644 crates/nip29_ui/src/details.rs create mode 100644 crates/nip29_ui/src/invite.rs create mode 100644 crates/nip29_ui/src/lib.rs create mode 100644 crates/nip29_ui/src/message.rs create mode 100644 crates/nip29_ui/src/metadata.rs create mode 100644 crates/workspace/src/dialogs/join_group.rs delete mode 100644 crates/workspace/src/dialogs/new_community.rs create mode 100644 crates/workspace/src/dialogs/new_group.rs create mode 100644 crates/workspace/src/dialogs/profile.rs delete mode 100644 docs/concord-usage.md create mode 100644 docs/nip29-migration-plan.md create mode 100644 docs/nip29-usage.md diff --git a/Cargo.lock b/Cargo.lock index 64364dd7..0f72adfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1276,41 +1276,6 @@ dependencies = [ "regex", ] -[[package]] -name = "community" -version = "1.0.2" -dependencies = [ - "anyhow", - "concord", - "flume 0.11.1", - "futures", - "gpui-pre", - "log", - "nostr-memory", - "nostr-sdk", - "serde_json", - "smallvec", - "smol", - "state", -] - -[[package]] -name = "community_ui" -version = "1.0.2" -dependencies = [ - "anyhow", - "common", - "community", - "gpui-pre", - "nostr-sdk", - "person", - "settings", - "smallvec", - "state", - "theme", - "ui", -] - [[package]] name = "compression-codecs" version = "0.4.43" @@ -1449,7 +1414,6 @@ dependencies = [ "auto_update", "chat", "common", - "community", "device", "gpui-pre", "gpui-pre-linux", @@ -1458,6 +1422,7 @@ dependencies = [ "gpui-pre-reqwest-client", "gpui-pre-windows", "log", + "nip29", "nostr-sdk", "person", "settings", @@ -1475,7 +1440,6 @@ dependencies = [ "assets", "chat", "common", - "community", "console_error_panic_hook", "console_log", "device", @@ -1487,6 +1451,7 @@ dependencies = [ "gpui-pre-web", "instant", "log", + "nip29", "person", "settings", "state", @@ -4593,6 +4558,44 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nip29" +version = "1.0.2" +dependencies = [ + "anyhow", + "chat", + "flume 0.11.1", + "futures", + "gpui-pre", + "log", + "nostr", + "nostr-sdk", + "serde_json", + "smallvec", + "state", +] + +[[package]] +name = "nip29_ui" +version = "1.0.2" +dependencies = [ + "anyhow", + "chat", + "chat_ui", + "common", + "gpui-pre", + "log", + "nip29", + "nostr-sdk", + "person", + "serde", + "settings", + "smallvec", + "state", + "theme", + "ui", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -9479,12 +9482,12 @@ dependencies = [ "chat", "chat_ui", "common", - "community", - "community_ui", "device", "gpui-pre", "instant", "log", + "nip29", + "nip29_ui", "nostr-connect", "nostr-sdk", "person", diff --git a/Cargo.toml b/Cargo.toml index 50839b5b..8ca3bc30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ nostr-blossom = { git = "https://github.com/rust-nostr/nostr" } nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } -nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] } +nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] } # Crypto (NIP-17 encrypted file messages) aes-gcm = "0.10" diff --git a/assets/icons/ban.svg b/assets/icons/ban.svg new file mode 100644 index 00000000..0129765f --- /dev/null +++ b/assets/icons/ban.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/edit.svg b/assets/icons/edit.svg new file mode 100644 index 00000000..9387f08a --- /dev/null +++ b/assets/icons/edit.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/login.svg b/assets/icons/login.svg new file mode 100644 index 00000000..dc5ec458 --- /dev/null +++ b/assets/icons/login.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/logout.svg b/assets/icons/logout.svg new file mode 100644 index 00000000..05534526 --- /dev/null +++ b/assets/icons/logout.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/upload.svg b/assets/icons/upload.svg index cfb5e305..d92353e7 100644 --- a/assets/icons/upload.svg +++ b/assets/icons/upload.svg @@ -1,3 +1 @@ - - - + diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 7cdd7670..7c180368 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -53,7 +53,7 @@ static EMOJI_RE: LazyLock = mod actions; mod file; -mod text; +pub mod text; pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| ChatPanel::new(room, window, cx)) diff --git a/crates/community/src/cache.rs b/crates/community/src/cache.rs deleted file mode 100644 index 12ce5e18..00000000 --- a/crates/community/src/cache.rs +++ /dev/null @@ -1,331 +0,0 @@ -use std::collections::BTreeMap; -use std::sync::LazyLock; - -use anyhow::{Result, anyhow}; -use concord::cord01::OpenedStream; -use concord::state::{CommunityState, STATE_PREFIX, state_identifier}; -use concord::{ChannelId, CommunityId, cord03}; -use nostr_sdk::prelude::*; - -static LOCAL_KEYS: LazyLock = LazyLock::new(|| { - Keys::new(SecretKey::from_slice(&[0x43; 32]).expect("a fixed 32-byte scalar is a valid key")) -}); - -const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C; -const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T; -const MARK_VALUE: &str = "concord"; -const WRAP_TAG: &str = "e"; -const KIND_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_K; - -/// An already-expired rumor is refused at ingest. Returns whether it was kept. -pub async fn cache_rumor( - client: &Client, - channel: &ChannelId, - opened: &OpenedStream, -) -> Result { - let at = Timestamp::from_secs(opened.at_ms / 1000); - - if cord03::expiration_of(&opened.rumor)? - .is_some_and(|expiration| expiration <= Timestamp::now()) - { - return Ok(false); - } - - let tags = vec![ - Tag::identifier(opened.rumor_id), - Tag::custom(KIND_TAG.as_str(), [opened.rumor.kind.to_string()]), - Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]), - Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]), - Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]), - Tag::public_key(opened.author), - ]; - - let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json()) - .tags(tags) - .custom_created_at(at) - .finalize_async(&*LOCAL_KEYS) - .await?; - - client.database().save_event(&event).await?; - - Ok(true) -} - -pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) -> Result { - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .custom_tag(MARK_TAG, MARK_VALUE) - .custom_tag(CHANNEL_TAG, channel.to_hex()); - - let mut expired = Vec::new(); - - for event in client.database().query(filter).await? { - let Ok(rumor) = UnsignedEvent::from_json(&event.content) else { - continue; - }; - - let Ok(Some(expiration)) = cord03::expiration_of(&rumor) else { - continue; - }; - - if expiration <= now { - expired.push(event.id); - } - } - - let purged = expired.len(); - - if purged > 0 { - client.database().delete(Filter::new().ids(expired)).await?; - } - - Ok(purged) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Observed { - pub author: PublicKey, - pub at_ms: u64, -} - -/// The cached rumors of `channel`, keyed by the wrap they were opened from. -pub async fn wrapper_index( - client: &Client, - channel: &ChannelId, -) -> Result> { - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .custom_tag(MARK_TAG, MARK_VALUE) - .custom_tag(CHANNEL_TAG, channel.to_hex()); - - let mut index = BTreeMap::new(); - - for event in client.database().query(filter).await? { - let (Some(wrapper_id), Some(author)) = ( - event.tags.event_ids().next(), - event.tags.public_keys().next(), - ) else { - continue; - }; - - index.insert( - wrapper_id, - Observed { - author, - at_ms: event.created_at.as_secs().saturating_mul(1000), - }, - ); - } - - Ok(index) -} - -/// Cached rumors for `channel`, newest first, deduplicated by rumor id. -pub async fn query_rumors( - client: &Client, - channel: &ChannelId, - until: Option, - limit: usize, - kinds: Option<&[u16]>, -) -> Result> { - let mut filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .custom_tag(MARK_TAG, MARK_VALUE) - .custom_tag(CHANNEL_TAG, channel.to_hex()); - - if let Some(kinds) = kinds { - filter = filter.custom_tags(KIND_TAG, kinds.iter().map(u16::to_string)); - } - - if let Some(until) = until { - filter = filter.until(until); - } - - let mut newest: BTreeMap = BTreeMap::new(); - for event in client.database().query(filter).await? { - let Some(rumor_id) = event.tags.identifier() else { - continue; - }; - - match newest.get(&rumor_id) { - Some(existing) if existing.created_at >= event.created_at => {} - _ => { - newest.insert(rumor_id, event); - } - } - } - - let mut events: Vec = newest.into_values().collect(); - events.sort_by_key(|event| std::cmp::Reverse(event.created_at)); - events.truncate(limit); - - let mut rumors = Vec::with_capacity(events.len()); - for event in events { - let rumor = UnsignedEvent::from_json(event.content) - .map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?; - rumors.push(rumor); - } - - Ok(rumors) -} - -pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> { - let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) - .tags([Tag::identifier(state.identifier())]) - .finalize_async(&*LOCAL_KEYS) - .await?; - - client.database().save_event(&event).await?; - - Ok(()) -} - -pub async fn load_state(client: &Client, id: &CommunityId) -> Result> { - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(state_identifier(id)) - .limit(1); - - match client.database().query(filter).await?.into_iter().next() { - Some(event) => Ok(Some(serde_json::from_str(&event.content)?)), - None => Ok(None), - } -} - -/// The newest state document per community carried in the local database. -pub async fn load_states(client: &Client) -> Result> { - let filter = Filter::new().kind(Kind::ApplicationSpecificData); - let mut newest: BTreeMap = BTreeMap::new(); - - for event in client.database().query(filter).await? { - let Some(id) = state_document_of(&event) else { - continue; - }; - - match newest.get(&id) { - Some(existing) if existing.created_at >= event.created_at => {} - _ => { - newest.insert(id, event); - } - } - } - - let mut states = Vec::with_capacity(newest.len()); - - for event in newest.into_values() { - match serde_json::from_str::(&event.content) { - Ok(state) => states.push(state), - Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id), - } - } - - Ok(states) -} - -fn state_document_of(event: &Event) -> Option { - let identifier = event.tags.identifier()?; - let hex = identifier.strip_prefix(STATE_PREFIX)?; - hex.parse().ok() -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeSet; - - use concord::Epoch; - use nostr_memory::MemoryDatabase; - - use super::*; - - fn client() -> Client { - ClientBuilder::default() - .database(MemoryDatabase::unbounded()) - .build() - } - - #[test] - fn load_states_reads_one_document_per_community_and_ignores_other_documents() { - smol::block_on(async { - let client = client(); - - let state = CommunityState { - id: CommunityId::from_bytes([0x42; 32]), - name: Some("Anime and Manga".to_owned()), - owner: Keys::generate().public_key(), - owner_salt: [0x01; 32], - community_root: [0x02; 32], - root_epoch: Epoch(0), - control_root: None, - control_pks: BTreeMap::new(), - channels: Vec::new(), - relays: Vec::new(), - heads: Vec::new(), - banned: BTreeSet::new(), - cursors: BTreeMap::new(), - held_roots: Vec::new(), - channel_cuts: BTreeMap::new(), - refounders: BTreeSet::new(), - removed_at: None, - stranded: false, - dissolved: false, - added_at_ms: 7, - }; - - save_state(&client, &state).await.expect("saves"); - - // A cached rumor is also an application-specific document, but not a - // state document, so the prefix keeps it out of the state scan. - let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}") - .tags([Tag::identifier("deadbeef")]) - .finalize(&*LOCAL_KEYS) - .expect("builds"); - client.database().save_event(&other).await.expect("saves"); - - let loaded = load_states(&client).await.expect("loads"); - - assert_eq!(loaded, vec![state]); - }); - } - - #[test] - fn caching_the_same_rumor_twice_leaves_one_row() { - smol::block_on(async { - let client = client(); - let channel = ChannelId::from_bytes([0x9c; 32]); - let keys = Keys::generate(); - let group = concord::derive::channel_group_key(&[0x07; 32], &channel, Epoch(0)) - .expect("a group key"); - - let rumor = concord::cord03::build_message( - keys.public_key(), - &channel, - Epoch(0), - "twice", - None, - 1_700_000_000_000, - None, - ); - let (wrap, _) = concord::cord03::seal_rumor(&rumor, &group, &keys, false) - .await - .expect("seals"); - let (opened, _) = - concord::cord03::open(&wrap, &group, &channel, Epoch(0)).expect("opens"); - - assert!( - cache_rumor(&client, &channel, &opened) - .await - .expect("caches") - ); - assert!( - cache_rumor(&client, &channel, &opened) - .await - .expect("caches") - ); - - let cached = query_rumors(&client, &channel, None, 10, None) - .await - .expect("reads"); - assert_eq!(cached.len(), 1); - }); - } -} diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs deleted file mode 100644 index e5cd9a24..00000000 --- a/crates/community/src/community.rs +++ /dev/null @@ -1,1258 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::path::PathBuf; -use std::time::{Duration, Instant}; - -use anyhow::Result; -use concord::cord02::{ControlFold, ImageRef}; -use concord::cord03::{self, ChatMessage, ReplyRef}; -use concord::cord04::AuthorityCitation; -use concord::cord04::roles::{Permissions, citation_ok}; -use concord::cord06::RekeyScope; -use concord::derive::{channel_group_key, grant_locator}; -use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, HeldRoot}; -use concord::{ChannelId, CommunityId, Epoch}; -use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; -use nostr_sdk::prelude::*; -use smallvec::{SmallVec, smallvec}; -use state::NostrRegistry; - -use crate::cache; -use crate::history::{self, PageRegistry, Window, WrapPage}; -use crate::rekey::{self, Adoptions}; -use crate::sync::{self, Snapshot}; - -/// Wraps one relay returns for one page request. -const PAGE_WRAPS: usize = 50; -/// Pages a catch-up round walks down a channel's history on its own. -const CATCH_UP_PAGES: usize = 20; -/// Pages one explicit "load older" fetches from the relays. -pub const LOAD_OLDER_PAGES: usize = 6; -/// Rows one timeline read returns before the caller asks for more. -pub const TIMELINE_PAGE: usize = 100; -/// Side events read per row, so a reaction flood cannot displace the rows it decorates. -const SIDE_EVENT_FACTOR: usize = 4; -/// The shortest gap between two automatic catch-up rounds for one channel. -pub const MIN_ROUND_INTERVAL: Duration = Duration::from_secs(30); -/// How long a channel may go unsynced before the scheduler repairs it. -const STALE_AFTER: Duration = Duration::from_secs(300); - -/// Which direction a sync round reads. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Intent { - CatchUp, - Older { pages: usize }, -} - -/// What one round saw, for the caller to report and act on. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct Progress { - /// Wraps the round reached that no key this client holds can open. - pub unreadable: usize, - /// A relay refused or never answered a page. - pub failed: bool, - /// How many relays failed the round. - pub errors: usize, -} - -/// A channel's timeline, folded from the local cache. -#[derive(Debug, Clone, Default)] -pub struct Timeline { - /// Oldest first, ready for a bottom-aligned list. - pub messages: Vec, - /// The cache holds rows older than `messages`. - pub has_more: bool, -} - -/// One channel's round in flight, coalescing requests that arrive while it runs. -#[derive(Default)] -struct Round { - running: bool, - /// The next round to run once this one lands. - queued: Option, - /// Everyone waiting on the outcome. - waiters: Vec>>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SubscriptionKey { - control_pks: BTreeMap, - channels: Vec<(ChannelId, Epoch, bool)>, - roots: Vec<(u64, [u8; 32])>, - relays: Vec, -} - -impl SubscriptionKey { - fn of(state: &CommunityState) -> Self { - Self { - control_pks: state.control_pks.clone(), - channels: state - .channels - .iter() - .map(|channel| (channel.id, channel.epoch, channel.private)) - .collect(), - roots: state - .roots() - .into_iter() - .map(|root| (root.epoch.0, root.key)) - .collect(), - relays: state.relays.clone(), - } - } - - pub(crate) fn relays(&self) -> &[RelayUrl] { - &self.relays - } -} - -#[derive(Debug, Clone)] -pub enum CommunityEvent { - Updated(CommunityId), - Channel(CommunityId, ChannelId), - /// History exists here that no held key can open. - Unreadable(CommunityId), - /// The last round could not reach the community's relays. - Failed(CommunityId), - Error(String), -} - -pub struct Community { - state: CommunityState, - control: ControlFold, - members: BTreeSet, - active: Option, - icon: Option, - icon_ref: Option, - banner: Option, - banner_ref: Option, - dirty: bool, - refresh_task: Option>>, - icon_task: Option>>, - banner_task: Option>>, - rounds: HashMap, - /// The last completed round per channel, for the panel's honest states. - progress: HashMap, - /// Wraps held per channel that no key we hold can open. - unreadable: BTreeMap, - /// When each channel's last round started, which paces the automatic ones. - last_round: HashMap, - pages: PageRegistry, - rekey_task: Option>>, - rekey_dirty: bool, - /// Spawned folds, round bookkeeping and publishes, cancelled on drop - tasks: SmallVec<[Task>; 2]>, -} - -impl EventEmitter for Community {} - -impl Community { - pub fn new(state: CommunityState, pages: PageRegistry) -> Self { - Self { - state, - control: ControlFold::default(), - members: BTreeSet::new(), - active: None, - icon: None, - icon_ref: None, - banner: None, - banner_ref: None, - dirty: false, - refresh_task: None, - icon_task: None, - banner_task: None, - rounds: HashMap::new(), - progress: HashMap::new(), - unreadable: BTreeMap::new(), - last_round: HashMap::new(), - pages, - rekey_task: None, - rekey_dirty: false, - tasks: smallvec![], - } - } - - pub fn id(&self) -> CommunityId { - self.state.id - } - - pub fn state(&self) -> &CommunityState { - &self.state - } - - pub fn name(&self) -> SharedString { - if let Some(metadata) = &self.control.community { - return SharedString::from(&metadata.name); - } - - self.state - .name - .as_ref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from("Unknown")) - } - - pub fn control(&self) -> &ControlFold { - &self.control - } - - /// The community's icon, once downloaded and decrypted into a cache file. - pub fn icon(&self) -> Option { - self.icon.clone() - } - - /// The community's banner, once downloaded and decrypted into a cache file. - pub fn banner(&self) -> Option { - self.banner.clone() - } - - /// The channel the sidebar and panel show, defaulting to the first one. - pub fn active_channel(&self) -> Option { - self.active - .or_else(|| self.state.channels.first().map(|channel| channel.id)) - } - - /// Mark `channel` as the one the sidebar and panel show. - pub fn set_active_channel(&mut self, channel: ChannelId, cx: &mut Context) { - if self.active == Some(channel) { - return; - } - - self.active = Some(channel); - cx.emit(CommunityEvent::Channel(self.state.id, channel)); - } - - pub fn members(&self) -> &BTreeSet { - &self.members - } - - /// The base epoch a rotation excluded us at: readable history, no writes. - pub fn removed_at(&self) -> Option { - self.state.removed_at - } - - /// A complete rotation ahead of our epoch predates our join and - /// carries no blob for us, so the invite landed us on a superseded epoch. - pub fn stranded(&self) -> bool { - self.state.stranded - } - - /// Wraps held here that no key we hold can open. - pub fn unreadable(&self, channel: &ChannelId) -> usize { - self.unreadable.get(channel).copied().unwrap_or(0) - } - - /// The last round's outcome for `channel`, when one has run. - pub fn progress(&self, channel: &ChannelId) -> Option { - self.progress.get(channel).copied() - } - - /// The epoch a private channel's key is missing for, when we hold none. - pub fn missing_key(&self, channel: &ChannelId) -> Option { - self.state - .channels - .iter() - .find(|held| held.id == *channel && held.private && held.key.is_none()) - .map(|held| held.epoch) - } - - /// The epoch a channel rotation removed us at, when it removed us. - pub fn channel_removed_at(&self, channel: &ChannelId) -> Option { - self.state.channel_cuts.get(channel).copied() - } - - /// Whether an automatic catch-up for `channel` is worth asking for yet. - pub fn due(&self, channel: &ChannelId) -> bool { - self.last_round - .get(channel) - .is_none_or(|at| at.elapsed() >= MIN_ROUND_INTERVAL) - } - - /// Whether `channel` has been synced before and has since gone stale. - fn stale(&self, channel: &ChannelId) -> bool { - self.last_round - .get(channel) - .is_some_and(|at| at.elapsed() >= STALE_AFTER) - } - - pub fn channels(&self) -> &[ChannelKeyRef] { - &self.state.channels - } - - pub fn subscription_key(&self) -> SubscriptionKey { - SubscriptionKey::of(&self.state) - } - - /// A public channel derives its write plane from the community root. - fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])> { - if self.state.removed_at.is_some() || self.state.stranded { - return None; - } - - let held = self - .state - .channels - .iter() - .find(|held| held.id == *channel)?; - - if self.state.channel_cut(channel, held.epoch) { - return None; - } - - held.current().or_else(|| { - (!held.private).then_some((self.state.root_epoch, self.state.community_root)) - }) - } - - /// Every secret the client holds for a channel, newest epoch first. - fn held_keys(&self, channel: &ChannelId) -> Vec { - self.state.held_keys(channel) - } - - pub fn sync_channel( - &mut self, - channel: &ChannelId, - intent: Intent, - cx: &mut Context, - ) -> Task> { - let channel = *channel; - let (sender, receiver) = flume::bounded(1); - - let start = { - let round = self.rounds.entry(channel).or_default(); - round.waiters.push(sender); - - let start = !round.running; - - if !start { - round.queued = Some(intent); - } - - start - }; - - if start { - self.start_round(channel, intent, cx); - } - - cx.background_spawn(async move { - let progress = match receiver.recv_async().await { - Ok(progress) => progress, - Err(error) => { - log::warn!("community: channel round cancelled: {error}"); - return Ok(Progress { - failed: true, - ..Progress::default() - }); - } - }; - progress.map_err(anyhow::Error::msg) - }) - } - - fn start_round(&mut self, channel: ChannelId, intent: Intent, cx: &mut Context) { - let client = NostrRegistry::global(cx).read(cx).client(); - let held = self.held_keys(&channel); - let relays = self.state.relays.clone(); - let pages = self.pages.clone(); - - let saved = clamped( - self.state - .cursors - .get(&channel) - .copied() - .unwrap_or_default(), - Timestamp::now(), - ); - - if let Some(round) = self.rounds.get_mut(&channel) { - round.running = true; - round.queued = None; - } - - self.last_round.insert(channel, Instant::now()); - - let round = cx.background_spawn(async move { - sync_round(&client, &pages, &channel, &held, &relays, saved, intent).await - }); - - let finisher = cx.spawn(async move |this, cx| { - let result = round.await; - - if let Err(error) = this.update(cx, |this, cx| this.finish_round(channel, result, cx)) { - log::warn!("community: a channel round outlived its community: {error}"); - } - - Ok(()) - }); - - self.tasks.push(finisher); - } - - fn finish_round( - &mut self, - channel: ChannelId, - result: Result, - cx: &mut Context, - ) { - let outcome = match result { - Ok((progress, cursor)) => { - self.merge_cursor(channel, cursor, cx); - Ok(progress) - } - Err(error) => Err(error.to_string()), - }; - - let Some(round) = self.rounds.get_mut(&channel) else { - return; - }; - - round.running = false; - let queued = round.queued.take(); - let waiters = std::mem::take(&mut round.waiters); - - for waiter in waiters { - if let Err(error) = waiter.try_send(outcome.clone()) { - log::warn!("community: a channel round result was not delivered: {error}"); - } - } - - if let Some(intent) = queued { - self.start_round(channel, intent, cx); - } - - if let Ok(progress) = &outcome { - self.progress.insert(channel, *progress); - self.record_unreadable(channel, progress.unreadable); - - if progress.failed && progress.errors > 0 { - cx.emit(CommunityEvent::Failed(self.state.id)); - } - - if progress.unreadable > 0 { - cx.emit(CommunityEvent::Unreadable(self.state.id)); - } - } - - cx.emit(CommunityEvent::Updated(self.state.id)); - } - - /// Remember the wraps no held key could open. - fn record_unreadable(&mut self, channel: ChannelId, count: usize) { - if count == 0 { - return; - } - - let seen = self.unreadable.entry(channel).or_default(); - *seen = (*seen).max(count); - } - - fn missing_authority(&self) -> bool { - self.state.refounders.is_empty() && self.state.roots().len() > 1 - } - - pub(crate) fn tick(&mut self, cx: &mut Context) { - if self.missing_authority() { - self.rekey(cx); - } - - let Some(channel) = self.active_channel() else { - return; - }; - - if !self.stale(&channel) { - return; - } - - self.refresh(cx); - - let catch_up = self.sync_channel(&channel, Intent::CatchUp, cx); - - let task = cx.spawn(async move |_this, _cx| { - if let Err(error) = catch_up.await { - log::warn!("community: the scheduler's catch-up failed: {error}"); - } - - Ok(()) - }); - - self.tasks.push(task); - } - - /// Fold a round's cursor findings in, monotonically, and persist them. - fn merge_cursor(&mut self, channel: ChannelId, cursor: ChannelCursor, cx: &mut Context) { - let held = self - .state - .cursors - .get(&channel) - .copied() - .unwrap_or_default(); - - let merged = clamped(held.merge(cursor), Timestamp::now()); - - if merged == held { - return; - } - - self.state.cursors.insert(channel, merged); - self.persist(cx); - } - - /// Write the local state document out. - fn persist(&mut self, cx: &Context) { - let client = NostrRegistry::global(cx).read(cx).client(); - let state = self.state.clone(); - - let task = cx.background_spawn(async move { - if let Err(error) = cache::save_state(&client, &state).await { - log::warn!("community: failed to persist the local state: {error}"); - } - - Ok(()) - }); - - self.tasks.push(task); - } - - /// The channel's timeline, folded from the local cache, oldest first. - pub fn timeline( - &self, - channel: &ChannelId, - before_ms: Option, - limit: usize, - cx: &App, - ) -> Task> { - let client = NostrRegistry::global(cx).read(cx).client(); - let channel = *channel; - let owner = self.state.owner; - let community_id = self.state.id; - let floors = self.state.floors(); - let roles = self.control.roles.clone(); - - cx.background_spawn(async move { - let until = before_ms.map(|before_ms| Timestamp::from_secs(before_ms / 1000)); - - let rows = cache::query_rumors( - &client, - &channel, - until, - limit.saturating_add(1), - Some(&cord03::ROW_KINDS), - ) - .await?; - - let has_more = rows.len() > limit; - - let sides = cache::query_rumors( - &client, - &channel, - until, - limit.saturating_mul(SIDE_EVENT_FACTOR), - Some(&cord03::SIDE_KINDS), - ) - .await?; - - let mut rumors = Vec::with_capacity(rows.len() + sides.len()); - - for rumor in rows.iter().take(limit).chain(sides.iter()) { - match cord03::parse_rumor(rumor) { - Ok(chat) => rumors.push(chat), - Err(error) => { - log::warn!("community: skipping an unreadable cached rumor: {error}") - } - } - } - - let mut messages = - cord03::fold(&rumors, Timestamp::now(), |actor, citation, author| { - citation_ok(&owner, &community_id, actor, citation, &floors) - && roles.can_act_on_member( - actor, - &owner, - author, - Permissions::MANAGE_MESSAGES, - ) - }); - - messages.reverse(); - - Ok(Timeline { messages, has_more }) - }) - } - - /// Seal a message to the channel plane, cache it, then publish it to the relays. - pub fn send( - &self, - channel: &ChannelId, - content: &str, - reply_to: Option, - cx: &App, - ) -> Option>> { - let (epoch, secret) = self.channel_secret(channel)?; - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - let author = nostr.read(cx).current_user()?; - - let channel = *channel; - let relays = self.state.relays.clone(); - let timer = self - .control - .community - .as_ref() - .and_then(|metadata| metadata.message_expiration); - let content = content.to_owned(); - - Some(cx.background_spawn(async move { - let group = channel_group_key(&secret, &channel, epoch)?; - let at_ms = now_ms()?; - - let rumor = cord03::build_message( - author, - &channel, - epoch, - &content, - reply_to.as_ref(), - at_ms, - timer, - ); - let (wrap, _) = cord03::seal_rumor(&rumor, &group, &signer, false).await?; - - let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?; - cache::cache_rumor(&client, &channel, &opened).await?; - - sync::connect_relays(&client, &relays).await; - sync::publish_wrap(&client, &wrap, &relays).await; - - Ok(opened.rumor_id) - })) - } - - /// Adopt plane material the account's list now carries. - /// - /// The caller re-folds afterwards; this only seeds the new planes. - pub(crate) fn adopt(&mut self, state: CommunityState) { - // A fold already in flight would write its pre-adoption state back. - self.refresh_task = None; - self.dirty = false; - self.state = state; - } - - /// Adopt whatever the rekey watch has delivered, then re-page what it moved. - pub fn rekey(&mut self, cx: &mut Context) { - if self.rekey_task.is_some() { - self.rekey_dirty = true; - return; - } - - let nostr = NostrRegistry::global(cx); - let Some(me) = nostr.read(cx).current_user() else { - return; - }; - - let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - let state = self.state.clone(); - let roles = self.control.roles.clone(); - - let task = - cx.background_spawn( - async move { rekey::adopt(&client, &state, &roles, &signer, me).await }, - ); - - self.rekey_task = Some(cx.spawn(async move |this, cx| { - let result = task.await; - this.update(cx, |this, cx| this.apply_rekey(result, cx))?; - Ok(()) - })); - } - - /// Rotate one scope to its next epoch, cutting off `excluded`. - pub fn rotate( - &mut self, - scope: RekeyScope, - recipients: &[PublicKey], - excluded: &[PublicKey], - cx: &mut Context, - ) -> Option>> { - let nostr = NostrRegistry::global(cx); - let me = nostr.read(cx).current_user()?; - - let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - - if self.state.removed_at.is_some() || self.state.stranded || self.state.banned.contains(&me) - { - return None; - } - - let rewrite = rekey::Rewrite { - scope, - recipients: recipients.to_vec(), - excluded: excluded.to_vec(), - citation: self.citation(&me), - }; - - if !rewrite.authorized(&self.control.roles, &self.state.owner, &me) { - return None; - } - - let state = self.state.clone(); - let roles = self.control.roles.clone(); - - Some(cx.spawn(async move |this, cx| { - let epoch = rekey::rotate( - &client, - &state, - &roles, - &signer, - me, - &rewrite, - Timestamp::now(), - ) - .await?; - - let adoptions = rekey::adopt(&client, &state, &roles, &signer, me).await?; - - this.update(cx, |this, cx| { - if !adoptions.is_empty() { - this.merge_adoptions(adoptions, cx); - } - })?; - - Ok(epoch) - })) - } - - /// The rank this client cites when it acts. - fn citation(&self, me: &PublicKey) -> Option { - let entity = grant_locator(&self.state.id, &me.to_bytes()); - - self.state - .heads - .iter() - .find(|head| head.entity == entity) - .map(|head| AuthorityCitation { - entity: head.entity, - version: head.version, - hash: head.self_hash, - }) - } - - fn apply_rekey(&mut self, result: Result, cx: &mut Context) { - self.rekey_task = None; - - match result { - Ok(adoptions) if !adoptions.is_empty() => self.merge_adoptions(adoptions, cx), - Ok(_) => {} - Err(error) => cx.emit(CommunityEvent::Error(error.to_string())), - } - - if self.rekey_dirty { - self.rekey_dirty = false; - self.rekey(cx); - } - } - - /// Fold an adoption into the held state, persist it, and re-page what moved. - fn merge_adoptions(&mut self, adoptions: Adoptions, cx: &mut Context) { - let mut touched: Vec = Vec::new(); - - if let Some(base) = adoptions.base { - let mut held = Vec::with_capacity(base.stepped.len() + self.state.held_roots.len()); - - for key in base.stepped { - let known = self - .state - .held_roots - .iter() - .chain(held.iter()) - .any(|root| root.epoch == key.epoch && root.key == key.key); - - if known { - continue; - } - - held.push(HeldRoot { - epoch: key.epoch, - key: key.key, - control_pk: self.state.control_pks.get(&key.epoch.0).copied(), - retired_at: key.retired_at, - }); - } - - for root in &self.state.held_roots { - if held - .iter() - .any(|kept| kept.epoch == root.epoch && kept.key == root.key) - { - continue; - } - - held.push(*root); - } - - self.state.held_roots = held; - - if let Some(control_pk) = base.control_pk { - self.state.control_pks.insert(base.epoch.0, control_pk); - } - - // The rotation is authoritative about the new epoch's signing root - self.state.control_root = base.control_root; - self.state.community_root = base.key; - self.state.root_epoch = base.epoch; - self.state.removed_at = None; - self.state.stranded = false; - - // Every channel's plane moved with the root. - touched.extend(self.state.channels.iter().map(|channel| channel.id)); - } - - for channel in adoptions.channels { - let Some(held) = self - .state - .channels - .iter_mut() - .find(|held| held.id == channel.channel) - else { - continue; - }; - - // `stepped` carries the key held before the walk plus every epoch it - // passed through, each with the cutoff its superseding rotation set. - for key in channel.stepped { - if !held.priors.iter().any(|prior| prior.epoch == key.epoch) { - held.priors.push(key); - } - } - - held.key = Some(channel.key); - held.epoch = channel.epoch; - held.private = true; - touched.push(channel.channel); - } - - for (channel, epoch) in adoptions.cuts { - self.state.channels.retain(|held| held.id != channel); - self.state.channel_cuts.insert(channel, epoch); - self.state.cursors.remove(&channel); - } - - // A rotation that delivered us no key still names the npub that minted the epoch. - let learned = adoptions - .refounders - .into_iter() - .filter(|refounder| self.state.refounders.insert(*refounder)) - .count(); - - if let Some(epoch) = adoptions.removed_at { - self.state.removed_at = Some(epoch); - } - - if adoptions.stranded { - self.state.stranded = true; - } - - for channel in &touched { - if let Some(cursor) = self.state.cursors.get_mut(channel) { - cursor.exhausted = false; - } - } - - self.persist(cx); - cx.notify(); - cx.emit(CommunityEvent::Updated(self.state.id)); - - if learned > 0 { - self.refresh(cx); - } - - let Some(channel) = self - .active - .or_else(|| self.state.channels.first().map(|channel| channel.id)) - else { - return; - }; - - let catch_up = self.sync_channel(&channel, Intent::CatchUp, cx); - - let task = cx.spawn(async move |_this, _cx| { - if let Err(error) = catch_up.await { - log::warn!("community: the catch-up after a rekey failed: {error}"); - } - - Ok(()) - }); - - self.tasks.push(task); - } - - /// Rebuilds the community from the wraps in the local database. - pub fn refresh(&mut self, cx: &mut Context) { - if self.refresh_task.is_some() { - self.dirty = true; - return; - } - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let state = self.state.clone(); - let folded = cx.background_spawn(async move { sync::fold(&client, &state).await }); - - self.refresh_task = Some(cx.spawn(async move |this, cx| { - let result = folded.await; - this.update(cx, |this, cx| this.apply(result, cx))?; - Ok(()) - })); - } - - fn apply(&mut self, result: Result>, cx: &mut Context) { - self.refresh_task = None; - - match result { - Ok(Some(snapshot)) => { - let mut state = snapshot.state; - state.cursors = std::mem::take(&mut self.state.cursors); - self.state = state; - self.control = snapshot.control; - self.members = snapshot.members; - self.load_images(cx); - - // A fold reads every plane, so its count is the truth for every - // channel rather than a sample of the region one round read. - // Replacing it is what lets the count fall again once a key is - // adopted; a partial round only ever raises it. - let reported = !snapshot.unreadable.is_empty(); - self.unreadable = snapshot.unreadable; - - if reported { - cx.emit(CommunityEvent::Unreadable(self.state.id)); - } - - cx.emit(CommunityEvent::Updated(self.state.id)); - cx.notify(); - } - Ok(None) => {} - Err(error) => cx.emit(CommunityEvent::Error(error.to_string())), - } - - if self.dirty { - self.dirty = false; - self.refresh(cx); - } - } - - /// Resolve the folded icon and banner into local files. - fn load_images(&mut self, cx: &mut Context) { - let (icon, banner) = match self.control.community.as_ref() { - Some(metadata) => (metadata.icon.clone(), metadata.banner.clone()), - None => (None, None), - }; - - self.load_icon(icon, cx); - self.load_banner(banner, cx); - } - - fn load_icon(&mut self, icon: Option, cx: &mut Context) { - if self.icon_ref == icon { - return; - } - - self.icon_ref = icon.clone(); - self.icon = None; - - let Some(icon) = icon else { - return; - }; - - self.icon_task = Some(cx.spawn(async move |this, cx| { - match sync::resolve_image(&icon, cx).await { - Ok(path) => { - this.update(cx, |this, cx| { - this.icon = Some(path); - cx.notify(); - })?; - } - Err(error) => log::warn!("community icon: {error}"), - } - Ok(()) - })); - } - - fn load_banner(&mut self, banner: Option, cx: &mut Context) { - if self.banner_ref == banner { - return; - } - - self.banner_ref = banner.clone(); - self.banner = None; - - let Some(banner) = banner else { - return; - }; - - self.banner_task = Some(cx.spawn(async move |this, cx| { - match sync::resolve_image(&banner, cx).await { - Ok(path) => { - this.update(cx, |this, cx| { - this.banner = Some(path); - cx.notify(); - })?; - } - Err(error) => log::warn!("community banner: {error}"), - } - Ok(()) - })); - } -} - -/// One round's progress and the cursor material it earned. -type RoundOutcome = (Progress, ChannelCursor); - -/// A cursor as a filter bound may use it. -fn clamped(cursor: ChannelCursor, now: Timestamp) -> ChannelCursor { - ChannelCursor { - newest: cursor.newest.map(|newest| newest.min(now)), - ..cursor - } -} - -/// The wall clock in epoch milliseconds: a rumor's `ms` tag carries the part of -/// the second the message was written in, and the fold orders rows by it. -fn now_ms() -> Result { - let elapsed = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|error| anyhow::anyhow!("the system clock is before the Unix epoch: {error}"))?; - - u64::try_from(elapsed.as_millis()) - .map_err(|error| anyhow::anyhow!("the system clock is out of range: {error}")) -} - -/// Read a channel's history from the community's relays, in three passes. -async fn sync_round( - client: &Client, - pages: &PageRegistry, - channel: &ChannelId, - held: &[HeldKey], - relays: &[RelayUrl], - saved: ChannelCursor, - intent: Intent, -) -> Result { - let mut progress = Progress::default(); - let mut round = ChannelCursor::default(); - - // Expired rows drop at fold time otherwise, never from disk. - let purged = cache::purge_expired(client, channel, Timestamp::now()).await?; - - if purged > 0 { - log::debug!( - "community: purged {purged} expired rumor(s) from {}", - channel.to_hex() - ); - } - - let newest = match intent { - Intent::CatchUp => { - let page = history::page( - client, - pages, - channel, - held, - relays, - Window::opening(saved), - 1, - PAGE_WRAPS, - ) - .await?; - absorb(&mut progress, &page); - page - } - Intent::Older { .. } => WrapPage::default(), - }; - - let bridge = match (intent, newest.oldest, saved.newest) { - (Intent::CatchUp, Some(oldest), Some(saved_newest)) if oldest > saved_newest => { - let page = history::page( - client, - pages, - channel, - held, - relays, - Window::between(saved_newest, oldest), - CATCH_UP_PAGES, - PAGE_WRAPS, - ) - .await?; - absorb(&mut progress, &page); - page - } - // Nothing to bridge, so the newest region is already complete. - _ => WrapPage { - exhausted: true, - ..WrapPage::default() - }, - }; - - let resume = match intent { - Intent::CatchUp => saved.oldest.or(newest.oldest), - Intent::Older { .. } => saved.oldest, - }; - - let budget = match intent { - Intent::CatchUp => CATCH_UP_PAGES, - Intent::Older { pages } => pages, - }; - - // A channel already swept to the bottom has nothing older to ask for. - let older = match (saved.exhausted, resume) { - (true, _) => WrapPage { - exhausted: true, - ..WrapPage::default() - }, - (false, Some(until)) => { - let page = history::page( - client, - pages, - channel, - held, - relays, - Window::older_than(until), - budget, - PAGE_WRAPS, - ) - .await?; - absorb(&mut progress, &page); - page - } - (false, None) => WrapPage::default(), - }; - - if intent == Intent::CatchUp { - let complete = !newest.failed && bridge.exhausted; - let top = newest - .newest - .unwrap_or_default() - .max(bridge.newest.unwrap_or_default()); - - if complete && !top.is_zero() { - round.newest = Some(top); - } - } - - round.oldest = older.oldest.or(newest.oldest); - round.exhausted = older.exhausted; - - Ok((progress, round)) -} - -fn absorb(progress: &mut Progress, page: &WrapPage) { - progress.unreadable += page.unreadable; - progress.failed |= page.failed; - progress.errors += page.errors; -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A stamp ahead of the local clock must never become a cursor. - /// - /// A durable `newest` past `now` bounds every later REQ below a region that - /// has not happened yet, so the channel receives nothing at all — not live, - /// not by round — while its older history keeps working, which is exactly - /// what a peer with a fast clock (or a hostile stamp) would cause. - #[test] - fn a_future_stamp_cannot_push_a_cursor_past_now() { - let now = Timestamp::now(); - let held = ChannelCursor { - newest: Some(now - Duration::from_secs(30)), - oldest: Some(now - Duration::from_secs(90)), - exhausted: false, - }; - let round = ChannelCursor { - newest: Some(now + Duration::from_secs(86_400)), - oldest: None, - exhausted: true, - }; - - let merged = clamped(held.merge(round), now); - - assert_eq!(merged.newest, Some(now), "the frontier stops at the clock"); - assert_eq!(merged.oldest, Some(now - Duration::from_secs(90))); - assert!(merged.exhausted, "the round's other findings still land"); - - // A cursor already stored past `now` heals instead of staying deaf. - let poisoned = ChannelCursor { - newest: Some(now + Duration::from_secs(86_400)), - ..ChannelCursor::default() - }; - - assert_eq!(clamped(poisoned, now).newest, Some(now)); - } - - /// One public channel under a held root, and nothing else. - fn state(channel: ChannelId) -> CommunityState { - CommunityState { - id: CommunityId::from_bytes([0x42; 32]), - name: None, - owner: Keys::generate().public_key(), - owner_salt: [0x01; 32], - community_root: [0x02; 32], - root_epoch: Epoch(0), - control_root: None, - control_pks: BTreeMap::new(), - channels: vec![ChannelKeyRef { - id: channel, - name: "general".to_owned(), - private: false, - epoch: Epoch(0), - key: None, - priors: Vec::new(), - }], - relays: Vec::new(), - heads: Vec::new(), - banned: BTreeSet::new(), - cursors: BTreeMap::new(), - held_roots: Vec::new(), - channel_cuts: BTreeMap::new(), - refounders: BTreeSet::new(), - removed_at: None, - stranded: false, - dissolved: false, - added_at_ms: 0, - } - } - - fn community(state: CommunityState) -> Community { - Community::new(state, PageRegistry::default()) - } - - /// A rotation that excluded us leaves no key to write under: a wrap sealed - /// with the retired root would reach nobody who rotated with it. - #[test] - fn a_removed_member_holds_no_write_key() { - let channel = ChannelId::from_bytes([0x9c; 32]); - - // A public channel writes from the community root while we hold it. - assert_eq!( - community(state(channel)).channel_secret(&channel), - Some((Epoch(0), [0x02; 32])) - ); - - // A base removal closes the community... - let mut removed = state(channel); - removed.removed_at = Some(Epoch(1)); - assert_eq!(community(removed).channel_secret(&channel), None); - - // ...a strand closes it too, because the rotation moved past our epoch... - let mut stranded = state(channel); - stranded.stranded = true; - assert_eq!(community(stranded).channel_secret(&channel), None); - - // ...and a channel cut closes the one channel. - let mut cut = state(channel); - cut.channel_cuts.insert(channel, Epoch(0)); - assert_eq!(community(cut).channel_secret(&channel), None); - } -} diff --git a/crates/community/src/history.rs b/crates/community/src/history.rs deleted file mode 100644 index 7e8ecf4e..00000000 --- a/crates/community/src/history.rs +++ /dev/null @@ -1,764 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::future::Future; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard}; -use std::time::{Duration, Instant}; - -use anyhow::{Result, bail}; -use concord::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream}; -use concord::cord03::{self, ChatRumor}; -use concord::derive::channel_group_key; -use concord::state::{ChannelCursor, HeldKey}; -use concord::{ChannelId, GroupKey}; -use futures::future::{Either, select}; -use nostr_sdk::prelude::*; - -use crate::cache::cache_rumor; -use crate::sync::connect_relays; - -/// How long one relay is given to answer one page of history. -const PAGE_TIMEOUT: Duration = Duration::from_secs(10); -/// How far below a cursor a warm window reaches back. -pub const CURSOR_OVERLAP: Duration = Duration::from_secs(60); - -/// The region of history to read. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct Window { - pub until: Option, - pub since: Option, -} - -impl Window { - /// The newest wraps, with no older bound. - pub fn newest() -> Self { - Self::default() - } - - /// The wraps strictly older than `oldest`. - pub fn older_than(oldest: Timestamp) -> Self { - Self { - until: Some(oldest - 1u64), - since: None, - } - } - - /// The region between `since` and `oldest`, both inclusive. - pub fn between(since: Timestamp, oldest: Timestamp) -> Self { - Self { - until: Some(oldest - 1u64), - since: Some(since), - } - } - - /// The window a channel is opened with. - pub fn opening(cursor: ChannelCursor) -> Self { - match cursor.newest { - Some(newest) => Self { - since: Some(newest - CURSOR_OVERLAP), - until: None, - }, - None => Self::default(), - } - } -} - -/// What a relay said about one page subscription. -#[derive(Debug, Clone)] -pub enum Settled { - Replayed, - Refused(String), -} - -/// One relay's verdict on one page subscription. -#[derive(Debug, Clone)] -pub struct PageReport { - pub relay: RelayUrl, - pub outcome: Settled, -} - -/// The page subscriptions in flight, by subscription id. -pub struct PageRegistry { - pages: Arc>>>, -} - -impl Default for PageRegistry { - fn default() -> Self { - Self { - pages: Arc::new(Mutex::new(HashMap::new())), - } - } -} - -impl Clone for PageRegistry { - fn clone(&self) -> Self { - Self { - pages: Arc::clone(&self.pages), - } - } -} - -impl PageRegistry { - pub fn register(&self, id: SubscriptionId, sender: flume::Sender) { - self.lock().insert(id, sender); - } - - pub fn unregister(&self, id: &SubscriptionId) { - self.lock().remove(id); - } - - /// Forget every page still in flight, because its round is gone. - pub fn clear(&self) { - self.lock().clear(); - } - - /// Hand a relay's verdict to the page that owns `id`, when it is still waiting. - pub fn deliver(&self, id: &SubscriptionId, relay: RelayUrl, outcome: Settled) { - let sender = self.lock().get(id).cloned(); - - let Some(sender) = sender else { - return; - }; - - if let Err(error) = sender.try_send(PageReport { relay, outcome }) { - log::debug!("community: a page report was not delivered: {error}"); - } - } - - fn lock(&self) -> MutexGuard<'_, HashMap>> { - match self.pages.lock() { - Ok(pages) => pages, - Err(poisoned) => poisoned.into_inner(), - } - } -} - -/// What one paged fetch saw. -#[derive(Debug, Clone, Default)] -pub struct WrapPage { - pub opened: Vec, - pub raw: usize, - /// Wraps that reached us under a held plane but that no held key could open. - pub unreadable: usize, - pub newest: Option, - pub oldest: Option, - pub exhausted: bool, - pub failed: bool, - pub errors: usize, -} - -/// Walks a channel's history back over the community's own relays. -#[allow(clippy::too_many_arguments)] -pub async fn page( - client: &Client, - pages: &PageRegistry, - channel: &ChannelId, - held: &[HeldKey], - relays: &[RelayUrl], - window: Window, - max_pages: usize, - limit: usize, -) -> Result { - let planes: Vec<(HeldKey, GroupKey)> = held - .iter() - .map(|key| Ok((*key, channel_group_key(&key.key, channel, key.epoch)?))) - .collect::>>()?; - let authors: Vec = planes.iter().map(|(_, group)| group.pk()).collect(); - - if authors.is_empty() || relays.is_empty() || limit == 0 { - return Ok(WrapPage { - failed: true, - ..WrapPage::default() - }); - } - - // A REQ can only target a relay the pool already knows about. - connect_relays(client, relays).await; - - let mut walk = Walk::new(relays, window); - let mut opened = Vec::new(); - - for _ in 0..max_pages { - if walk.is_done() { - break; - } - - let filter = wrap_filter(&authors, walk.region(), limit); - let asked: Vec<(usize, RelayUrl)> = walk - .live() - .map(|index| (index, walk.url(index).clone())) - .collect(); - - let answers = ask_page(client, pages, &asked, &filter).await; - - for (index, url) in &asked { - match answers.get(url) { - Some(Settled::Replayed) => {} - Some(Settled::Refused(reason)) => { - log::warn!("community: relay {url} refused a history page: {reason}"); - walk.reject(*index); - } - None => { - log::warn!("community: relay {url} did not finish a history page"); - walk.reject(*index); - } - } - } - - let answered = client.database().query(filter).await?; - - for wrap in walk.accept(answered, limit) { - let Some((held, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey) - else { - continue; - }; - - let Ok((stream, rumor)) = read_under(&wrap, held, group, channel) else { - walk.unreadable += 1; - continue; - }; - - if cache_rumor(client, channel, &stream).await? { - opened.push(rumor); - } - } - } - - Ok(walk.finish(opened)) -} - -/// Opens one wrap under a held key. -fn read_under( - wrap: &Event, - held: &HeldKey, - group: &GroupKey, - channel: &ChannelId, -) -> Result<(OpenedStream, ChatRumor)> { - if held - .retired_at - .is_some_and(|retired| wrap.created_at > retired) - { - bail!("sealed after the key that reads it was retired"); - } - - Ok(cord03::open(wrap, group, channel, held.epoch)?) -} - -/// The one filter a page is asked for. -fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter { - let mut filter = Filter::new() - .kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)]) - .authors(authors.iter().copied()) - .limit(limit); - - if let Some(until) = window.until { - filter = filter.until(until); - } - - if let Some(since) = window.since { - filter = filter.since(since); - } - - filter -} - -/// One page's verdicts, by relay. -type PageAnswers = BTreeMap; - -async fn ask_page( - client: &Client, - pages: &PageRegistry, - asked: &[(usize, RelayUrl)], - filter: &Filter, -) -> PageAnswers { - let mut answers = PageAnswers::new(); - let distinct: BTreeSet = asked.iter().map(|(_, url)| url.clone()).collect(); - let mut targets: Vec<(RelayUrl, Vec)> = Vec::with_capacity(distinct.len()); - - for url in &distinct { - match client.relay(url).await { - Ok(Some(_)) => targets.push((url.clone(), vec![filter.clone()])), - Ok(None) => { - log::warn!("community: relay {url} is not in the pool for a history page"); - answers.insert( - url.clone(), - Settled::Refused("not in the relay pool".to_owned()), - ); - } - Err(error) => { - log::warn!("community: relay {url} could not be looked up: {error}"); - answers.insert(url.clone(), Settled::Refused(error.to_string())); - } - } - } - - if targets.is_empty() { - return answers; - } - - let id = history_subscription(); - let (sender, receiver) = flume::bounded(distinct.len()); - pages.register(id.clone(), sender); - - let options = SubscribeAutoCloseOptions::default() - .exit_policy(ReqExitPolicy::ExitOnEOSE) - .timeout(Some(PAGE_TIMEOUT)); - - match client - .subscribe(ReqTarget::manual(targets)) - .with_id(id.clone()) - .close_on(options) - .await - { - Ok(output) => { - for (url, reason) in output.failed { - answers.insert(url, Settled::Refused(reason)); - } - - let deadline = Instant::now() + PAGE_TIMEOUT; - - while answers.len() < distinct.len() { - let remaining = deadline.saturating_duration_since(Instant::now()); - - let Some(report) = within(remaining, receiver.recv_async()).await else { - break; - }; - - match report { - Ok(report) => { - answers.insert(report.relay, report.outcome); - } - Err(error) => { - log::warn!("community: a history page's reports were lost: {error}"); - break; - } - } - } - } - Err(error) => { - log::warn!("community: a history page REQ was refused: {error}"); - } - } - - pages.unregister(&id); - - answers -} - -pub(crate) fn auth_required(reason: &str) -> bool { - matches!( - MachineReadablePrefix::parse(reason), - Some(MachineReadablePrefix::AuthRequired) - ) -} - -fn history_subscription() -> SubscriptionId { - static NEXT: AtomicU64 = AtomicU64::new(0); - SubscriptionId::new(format!("history-{}", NEXT.fetch_add(1, Ordering::Relaxed))) -} - -async fn within(limit: Duration, future: F) -> Option -where - F: Future, -{ - let future = std::pin::pin!(future); - let deadline = std::pin::pin!(smol::Timer::after(limit)); - - match select(future, deadline).await { - Either::Left((output, _)) => Some(output), - Either::Right(_) => None, - } -} - -/// One pass over a channel's history, page by page. -#[derive(Debug)] -struct Walk { - relays: Vec, - since: Option, - /// The inclusive upper bound of the next page. - cursor: Option, - seen: BTreeSet, - newest: Option, - oldest: Option, - raw: usize, - errors: usize, - /// Wraps the caller could not read under any held key. - unreadable: usize, - /// A short page ended the walk. - bottom: bool, -} - -/// One relay's standing in a walk. -#[derive(Debug)] -struct Walker { - url: RelayUrl, - dead: bool, -} - -impl Walk { - fn new(relays: &[RelayUrl], window: Window) -> Self { - Self { - relays: relays - .iter() - .cloned() - .map(|url| Walker { url, dead: false }) - .collect(), - since: window.since, - cursor: window.until, - seen: BTreeSet::new(), - newest: None, - oldest: None, - raw: 0, - errors: 0, - unreadable: 0, - bottom: false, - } - } - - fn is_done(&self) -> bool { - self.bottom || self.relays.iter().all(|walker| walker.dead) - } - - /// The region the next page asks for. - fn region(&self) -> Window { - Window { - until: self.cursor, - since: self.since, - } - } - - fn live(&self) -> impl Iterator + '_ { - (0..self.relays.len()).filter(|&index| !self.relays[index].dead) - } - - fn url(&self, index: usize) -> &RelayUrl { - &self.relays[index].url - } - - fn reject(&mut self, index: usize) { - self.relays[index].dead = true; - self.errors += 1; - } - - fn accept(&mut self, page: BTreeSet, limit: usize) -> Vec { - if page.len() < limit { - self.bottom = true; - } - - let mut oldest: Option = None; - let mut events = Vec::with_capacity(page.len()); - - for event in page { - let at = event.created_at; - self.newest = Some(self.newest.map_or(at, |newest| newest.max(at))); - oldest = Some(oldest.map_or(at, |oldest| oldest.min(at))); - - if self.seen.insert(event.id) { - self.raw += 1; - events.push(event); - } - } - - // The walk's floor, which the round resumes the older pass from. - if let Some(oldest) = oldest { - self.oldest = Some(self.oldest.map_or(oldest, |held| held.min(oldest))); - } - - match oldest { - Some(oldest) if !oldest.is_zero() => self.cursor = Some(oldest - 1u64), - Some(_) => self.bottom = true, - None => {} - } - - events - } - - fn finish(self, opened: Vec) -> WrapPage { - let swept = self.bottom && self.errors == 0; - - WrapPage { - opened, - raw: self.raw, - unreadable: self.unreadable, - newest: self.newest, - oldest: self.oldest, - exhausted: swept && self.raw > 0, - failed: self.errors > 0 || (self.bottom && self.raw == 0), - errors: self.errors, - } - } -} - -#[cfg(test)] -mod tests { - use std::cmp::Reverse; - - use concord::Epoch; - use concord::cord03::{build_message, seal_rumor}; - use concord::derive::channel_group_key; - - use super::*; - - const SECRET: [u8; 32] = [0x07u8; 32]; - const NEXT_SECRET: [u8; 32] = [0x11u8; 32]; - - fn serve_page(database: &BTreeSet, window: Window, limit: usize) -> BTreeSet { - let mut events: Vec = database - .iter() - .filter(|event| { - window.until.is_none_or(|until| event.created_at <= until) - && window.since.is_none_or(|since| event.created_at >= since) - }) - .cloned() - .collect(); - - events.sort_by_key(|event| Reverse(event.created_at)); - events.truncate(limit); - events.into_iter().collect() - } - - fn relay_url(host: &str) -> RelayUrl { - RelayUrl::parse(&format!("wss://{host}.example.com")).expect("parses") - } - - #[test] - fn a_walk_pages_back_across_a_rekey() { - let channel = ChannelId::from_bytes([0x9cu8; 32]); - let author = Keys::generate(); - let held = [ - HeldKey { - epoch: Epoch(0), - key: SECRET, - retired_at: None, - }, - HeldKey { - epoch: Epoch(1), - key: NEXT_SECRET, - retired_at: None, - }, - ]; - let planes: Vec<(HeldKey, GroupKey)> = held - .iter() - .map(|key| { - ( - *key, - channel_group_key(&key.key, &channel, key.epoch).expect("derives"), - ) - }) - .collect(); - - // Three messages a second apart: a page boundary falls between each. - let base = 1_700_000_000_000; - let mut relay: BTreeSet = BTreeSet::new(); - - for (content, secret, epoch, at_ms) in [ - ("before the rekey", &SECRET, Epoch(0), base), - ("still before", &SECRET, Epoch(0), base + 1_000), - ("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000), - ] { - let group = channel_group_key(secret, &channel, epoch).expect("derives"); - let rumor = build_message( - author.public_key(), - &channel, - epoch, - content, - None, - at_ms, - None, - ); - relay.insert( - smol::block_on(seal_rumor(&rumor, &group, &author, false)) - .expect("seals") - .0, - ); - } - - let mut walk = Walk::new(&[relay_url("history")], Window::newest()); - let mut found = Vec::new(); - let mut pages = 0; - - while !walk.is_done() && pages < 10 { - pages += 1; - let page = serve_page(&relay, walk.region(), 2); - - for wrap in walk.accept(page, 2) { - let Some((held, group)) = - planes.iter().find(|(_, group)| group.pk() == wrap.pubkey) - else { - continue; - }; - - let (_, rumor) = cord03::open(&wrap, group, &channel, held.epoch).expect("opens"); - found.push(rumor); - } - } - - found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id)); - - let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect(); - assert_eq!( - contents, - ["after the rekey", "still before", "before the rekey"] - ); - - let page = walk.finish(Vec::new()); - assert!(page.exhausted); - assert!(!page.failed); - assert_eq!(page.raw, 3); - assert_eq!(page.newest, Some(Timestamp::from_secs(1_700_000_002))); - assert_eq!( - page.oldest, - Some(Timestamp::from_secs(1_700_000_000)), - "the walk reports its floor, which the older pass resumes below" - ); - } - - /// A wrap that reaches us and still will not open is history we cannot read, - /// not history that does not exist. - #[test] - fn a_wrap_no_held_key_can_open_reads_as_unreadable() { - let channel = ChannelId::from_bytes([0x9cu8; 32]); - let other = ChannelId::from_bytes([0x9du8; 32]); - let author = Keys::generate(); - let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives"); - let held = HeldKey { - epoch: Epoch(0), - key: SECRET, - retired_at: Some(Timestamp::from_secs(1_000)), - }; - - let wrap_at = |channel: &ChannelId, at_ms: u64| { - let rumor = build_message( - author.public_key(), - channel, - Epoch(0), - "sealed", - None, - at_ms, - None, - ); - smol::block_on(seal_rumor(&rumor, &group, &author, false)) - .expect("seals") - .0 - }; - - // Sealed before the rotation superseded this key, so it still reads. - let before = wrap_at(&channel, 999_000); - assert!(read_under(&before, &held, &group, &channel).is_ok()); - - // Sealed after the cutoff the rotation set on that key. - let after = wrap_at(&channel, 1_001_000); - assert!(read_under(&after, &held, &group, &channel).is_err()); - - // Sealed to this plane but bound to another channel. - let misbound = wrap_at(&other, 999_000); - assert!(read_under(&misbound, &held, &group, &channel).is_err()); - } - - #[test] - fn an_empty_answer_never_seals_the_channel() { - let database: BTreeSet = BTreeSet::new(); - let mut walk = Walk::new(&[relay_url("history")], Window::newest()); - - let page = serve_page(&database, walk.region(), 50); - assert!(walk.accept(page, 50).is_empty()); - - let page = walk.finish(Vec::new()); - assert!(page.failed); - assert!(!page.exhausted); - assert_eq!(page.raw, 0); - assert_eq!(page.oldest, None); - } - - #[test] - fn a_silent_relay_blocks_the_bottom() { - let database: BTreeSet = BTreeSet::new(); - let mut walk = Walk::new( - &[relay_url("history"), relay_url("archive")], - Window::newest(), - ); - - // One relay answered the empty page; the other never answered at all, so - // its share of the region was never read and the walk must not seal. - walk.reject(1); - let page = serve_page(&database, walk.region(), 50); - assert!(walk.accept(page, 50).is_empty()); - - let page = walk.finish(Vec::new()); - assert!(page.failed); - assert!(!page.exhausted); - assert_eq!(page.errors, 1); - } - - #[test] - fn a_page_boundary_is_exclusive() { - let database = BTreeSet::from([ - event_at(Timestamp::from_secs(1_700_000_000)), - event_at(Timestamp::from_secs(1_700_000_001)), - ]); - let mut walk = Walk::new(&[relay_url("history")], Window::newest()); - - let first = walk.accept(serve_page(&database, walk.region(), 1), 1); - let second = walk.accept(serve_page(&database, walk.region(), 1), 1); - - assert_eq!(first.len(), 1); - assert_eq!(second.len(), 1); - assert_ne!(first[0].id, second[0].id); - - let oldest = second - .iter() - .map(|event| event.created_at) - .min() - .expect("one wrap"); - assert_eq!(oldest, Timestamp::from_secs(1_700_000_000)); - } - - fn event_at(at: Timestamp) -> Event { - let keys = Keys::generate(); - EventBuilder::new(Kind::TextNote, "page") - .custom_created_at(at) - .finalize(&keys) - .expect("signs") - } - - /// A cold channel asks wide; a warm one resumes at the overlap above its - /// cursor, and `older_than` never includes the boundary event itself. - #[test] - fn a_cold_window_is_open_and_a_warm_one_resumes_at_the_overlap() { - assert_eq!(Window::opening(ChannelCursor::default()), Window::default()); - - let warm = Window::opening(ChannelCursor { - newest: Some(Timestamp::from_secs(2_000_000)), - oldest: Some(Timestamp::from_secs(1_000)), - exhausted: false, - }); - assert_eq!( - warm, - Window { - since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP), - until: None, - } - ); - - assert_eq!( - Window::older_than(Timestamp::from_secs(1_000)), - Window { - since: None, - until: Some(Timestamp::from_secs(999)), - } - ); - } - - /// A page whose round has moved on is simply a report nobody reads. - #[test] - fn a_page_that_moved_on_receives_nothing() { - let pages = PageRegistry::default(); - let id = SubscriptionId::new("concord-history-9"); - let (sender, receiver) = flume::bounded(1); - - pages.register(id.clone(), sender); - pages.unregister(&id); - pages.deliver(&id, relay_url("history"), Settled::Replayed); - - assert!(receiver.try_recv().is_err()); - } -} diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs deleted file mode 100644 index 71ca90fe..00000000 --- a/crates/community/src/lib.rs +++ /dev/null @@ -1,829 +0,0 @@ -use std::collections::{BTreeSet, HashMap}; -use std::time::{Duration, Instant}; - -use anyhow::Result; -pub use concord::cord02::CommunityMetadata; -pub use concord::cord03::{ChatMessage, ReplyRef}; -use concord::state::CommunityState; -pub use concord::{ChannelId, CommunityId, Epoch}; -use futures::future::{Either, select}; -use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; -use nostr_sdk::prelude::*; -use smallvec::{SmallVec, smallvec}; -use state::NostrRegistry; - -use crate::history::{PageRegistry, Settled, auth_required}; -use crate::rekey::WatchRegistry; - -pub mod cache; -mod community; -pub mod history; -mod rekey; -mod sync; - -pub use community::*; -pub use sync::*; - -/// How long a burst of relay notifications is collected before it is folded. -const PUMP_WINDOW: Duration = Duration::from_millis(200); -/// How long a community's relays may deliver nothing before -/// its standing subscription is torn down and re-issued. -const LIVE_ROTATE: Duration = Duration::from_secs(90); - -pub fn init(cx: &mut App) { - CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx); -} - -struct GlobalCommunityRegistry(Entity); - -impl Global for GlobalCommunityRegistry {} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum Signal { - Event(CommunityId), - List, - Rekey(CommunityId), -} - -/// Which standing subscription an id belongs to. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Route { - List, - Community(CommunityId), -} - -fn route_of(id: &SubscriptionId) -> Option { - if sync::is_list_subscription(id) { - return Some(Route::List); - } - - sync::community_of(id).map(Route::Community) -} - -/// What a window of relay notifications saw, waiting to be folded once. -#[derive(Default)] -struct Batch { - list: bool, - communities: BTreeSet, - rekeys: BTreeSet, -} - -/// Whether the pump should keep listening. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Flow { - Continue, - Stop, -} - -/// Fold one notification into the window, or settle a page it belongs to. -fn route( - notification: ClientNotification, - pages: &PageRegistry, - watches: &WatchRegistry, - batch: &mut Batch, -) -> Flow { - match notification { - ClientNotification::Event { - subscription_id, .. - } => match route_of(&subscription_id) { - Some(Route::List) => batch.list = true, - Some(Route::Community(id)) => { - batch.communities.insert(id); - } - None => { - if let Some(id) = watches.community_of(&subscription_id) { - batch.rekeys.insert(id); - } - } - }, - ClientNotification::Message { relay_url, message } => match *message { - RelayMessage::EndOfStoredEvents(id) => { - pages.deliver(&id, relay_url, Settled::Replayed); - } - RelayMessage::Closed { - subscription_id, - message, - } if !auth_required(&message) => { - pages.deliver( - &subscription_id, - relay_url, - Settled::Refused(message.into_owned()), - ); - } - _ => {} - }, - ClientNotification::Shutdown => return Flow::Stop, - } - - Flow::Continue -} - -/// Hand the window's signals to the foreground consumer, one per community. -async fn flush(tx: &flume::Sender, batch: &mut Batch) -> Result<()> { - for id in std::mem::take(&mut batch.communities) { - tx.send_async(Signal::Event(id)).await?; - } - - for id in std::mem::take(&mut batch.rekeys) { - tx.send_async(Signal::Rekey(id)).await?; - } - - if std::mem::take(&mut batch.list) { - tx.send_async(Signal::List).await?; - } - - Ok(()) -} - -impl EventEmitter for CommunityRegistry {} - -pub struct CommunityRegistry { - communities: Vec>, - index: HashMap>, - /// The plane set each community was last subscribed with - synced: HashMap, - /// When a relay last delivered something for a community, - /// which is the only evidence the standing subscription is alive. - last_event: HashMap, - /// One observer per tracked community, dropped on reset - observers: HashMap, - signal_tx: flume::Sender, - signal_rx: flume::Receiver, - /// The page subscriptions in flight, shared with the notification pump. - pages: PageRegistry, - /// The rekey watch subscriptions, resolved to their community. - watches: WatchRegistry, - tasks: SmallVec<[Task>; 2]>, - /// Notification listener task (cancelled on signer change) - notification_listener: Option>>, - /// Signal consumer task (cancelled on signer change) - signal_consumer: Option>>, - /// The round scheduler (cancelled on signer change) - scheduler: Option>>, - _subscriptions: SmallVec<[Subscription; 2]>, -} - -impl CommunityRegistry { - pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() - } - - fn set_global(state: Entity, cx: &mut App) { - cx.set_global(GlobalCommunityRegistry(state)); - } - - fn new(cx: &mut Context) -> Self { - let entity = cx.entity().downgrade(); - let nostr = NostrRegistry::global(cx); - let (tx, rx) = flume::bounded::(256); - let mut subscriptions = smallvec![]; - - subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| { - if event.signer_changed() { - this.reset(cx); - this.handle_notifications(cx); - this.subscribe_list(cx); - this.load(cx); - } - })); - - cx.defer(move |cx| { - entity - .update(cx, |this, cx| { - this.handle_notifications(cx); - if nostr.read(cx).current_user().is_some() { - this.subscribe_list(cx); - this.load(cx); - } - }) - .ok(); - }); - - Self { - communities: Vec::new(), - index: HashMap::new(), - synced: HashMap::new(), - last_event: HashMap::new(), - observers: HashMap::new(), - signal_tx: tx, - signal_rx: rx, - pages: PageRegistry::default(), - watches: WatchRegistry::default(), - tasks: smallvec![], - notification_listener: None, - signal_consumer: None, - scheduler: None, - _subscriptions: subscriptions, - } - } - - pub fn communities(&self) -> &[Entity] { - &self.communities - } - - pub fn community(&self, id: &CommunityId) -> Option> { - self.index.get(id).cloned() - } - - /// Create a community owned by the current account and begin tracking it. - pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let current_user = nostr.read(cx).current_user(); - - if current_user.is_none() { - cx.emit(CommunityEvent::Error( - "cannot create a community without an account".to_owned(), - )); - return; - } - - let signer = nostr.read(cx).signer(); - let client = nostr.read(cx).client(); - - let task = - cx.background_spawn(async move { sync::create(&client, &signer, &metadata).await }); - - self.tasks.push(cx.spawn(async move |this, cx| { - match task.await { - Ok(_state) => this.update(cx, |this, cx| this.load(cx))?, - Err(error) => { - this.update(cx, |_this, cx| { - cx.emit(CommunityEvent::Error(error.to_string())); - })?; - } - } - - Ok(()) - })); - } - - /// Forget the current account and cancel everything in flight. - pub fn reset(&mut self, cx: &mut Context) { - self.notification_listener = None; - self.signal_consumer = None; - self.scheduler = None; - self.tasks.clear(); - self.observers.clear(); - self.pages.clear(); - self.watches.clear(); - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let ids: Vec = self.index.keys().copied().collect(); - - for id in ids { - let client = client.clone(); - let subscription = sync::subscription_id(&id); - let rekey = rekey::subscription_id(&id); - - self.tasks.push(cx.background_spawn(async move { - client.unsubscribe(&subscription).await?; - client.unsubscribe(&rekey).await?; - Ok(()) - })); - } - - self.communities.clear(); - self.index.clear(); - self.synced.clear(); - self.last_event.clear(); - - cx.notify(); - } - - /// Subscribe to the account's community list. - fn subscribe_list(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let signer = nostr.read(cx).signer(); - let client = nostr.read(cx).client(); - - self.tasks.push(cx.spawn(async move |this, cx| { - let self_pk = signer.get_public_key_async().await?; - - if let Err(error) = sync::subscribe_list(&client, self_pk).await { - this.update(cx, |_this, cx| { - cx.emit(CommunityEvent::Error(error.to_string())); - })?; - } - - Ok(()) - })); - } - - /// Discover the account's communities in the local database. - fn load(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let signer = nostr.read(cx).signer(); - let client = nostr.read(cx).client(); - - let task = cx.background_spawn(async move { - let self_pk = signer.get_public_key_async().await?; - sync::load(&client, &signer, self_pk).await - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - match task.await { - Ok(states) => { - this.update(cx, |this, cx| this.track(states, cx))?; - } - Err(error) => { - this.update(cx, |_this, cx| { - cx.emit(CommunityEvent::Error(error.to_string())); - })?; - } - } - - Ok(()) - })); - } - - /// Replace the tracked communities with a freshly loaded set. - /// - /// A community that survives the reload keeps its entity, so an open panel - /// and a browsing sidebar stay pointed at a live community. - fn track(&mut self, states: Vec, cx: &mut Context) { - let mut communities = Vec::with_capacity(states.len()); - - for state in states { - let id = state.id; - - let community = match self.index.remove(&id) { - Some(community) => { - // The list can carry plane material the store does not. - if community.read(cx).state() != &state { - community.update(cx, |community, _cx| community.adopt(state)); - } - - community - } - None => { - let community = cx.new(|_| Community::new(state, self.pages.clone())); - - self.observers.insert( - id, - cx.observe(&community, |this, _community, cx| { - this.sync_subscriptions(cx); - cx.notify(); - }), - ); - - community - } - }; - - communities.push((id, community)); - } - - // Whatever the index still holds is no longer in the list. - let dropped: Vec = self.index.keys().copied().collect(); - - for id in dropped { - self.observers.remove(&id); - self.synced.remove(&id); - self.last_event.remove(&id); - } - - self.communities = communities - .iter() - .map(|(_, community)| community.clone()) - .collect(); - self.index = communities.into_iter().collect(); - - self.sync_subscriptions(cx); - - // A backlog already in the database produces no notification, so fold it once. - for community in self.communities.clone() { - community.update(cx, |community, cx| community.refresh(cx)); - } - - cx.notify(); - } - - fn refresh(&mut self, id: CommunityId, cx: &mut Context) { - let Some(community) = self.index.get(&id).cloned() else { - return; - }; - - community.update(cx, |community, cx| community.refresh(cx)); - } - - /// Adopt whatever a community's rekey watch has delivered. - fn rekey(&mut self, id: CommunityId, cx: &mut Context) { - let Some(community) = self.index.get(&id).cloned() else { - return; - }; - - community.update(cx, |community, cx| community.rekey(cx)); - } - - /// One scheduler pass: every community repairs itself if it has gone stale, - /// and any whose relays have gone quiet is re-subscribed. - fn tick(&mut self, cx: &mut Context) { - for community in self.communities.clone() { - community.update(cx, |community, cx| community.tick(cx)); - } - self.rotate_quiet(cx); - } - - /// Re-issue the standing subscription of every community that has been quiet for `LIVE_ROTATE` - fn rotate_quiet(&mut self, cx: &mut Context) { - let now = Instant::now(); - let ids: Vec = self.index.keys().copied().collect(); - let mut rotated = false; - - for id in ids { - let quiet = self - .last_event - .get(&id) - .is_none_or(|at| now.duration_since(*at) >= LIVE_ROTATE); - - if !quiet { - continue; - } - - self.synced.remove(&id); - rotated = true; - } - - if rotated { - self.sync_subscriptions(cx); - } - } - - /// Re-subscribe every community whose held planes moved. - fn sync_subscriptions(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - - for community in self.communities.clone() { - let client = nostr.read(cx).client(); - - let (id, key, state) = { - let community = community.read(cx); - ( - community.id(), - community.subscription_key(), - community.state().clone(), - ) - }; - - if self.synced.get(&id) == Some(&key) { - continue; - } - - let planes = match sync::planes(&state) { - Ok(planes) => planes, - Err(error) => { - cx.emit(CommunityEvent::Error(error.to_string())); - continue; - } - }; - - let subscription = sync::subscription_id(&id); - let filter = sync::live_filter(&planes, sync::live_window(&state, Timestamp::now())); - let relays = key.relays().to_vec(); - - // A fresh REQ counts as evidence of life for this community. - self.last_event.insert(id, Instant::now()); - - // The rekey watch is a second standing REQ over the same relays. - let watch = match rekey::watches(&state) { - Ok(watches) => rekey::watch_filter(&watches), - Err(error) => { - cx.emit(CommunityEvent::Error(error.to_string())); - continue; - } - }; - - let rekey_subscription = rekey::subscription_id(&id); - self.watches.register(rekey_subscription.clone(), id); - - self.synced.insert(id, key); - - self.tasks.push(cx.spawn(async move |this, cx| { - if let Err(error) = subscribe(&client, &subscription, &relays, filter).await { - this.update(cx, |_this, cx| { - cx.emit(CommunityEvent::Error(error.to_string())); - })?; - } - - if let Err(error) = subscribe(&client, &rekey_subscription, &relays, watch).await { - this.update(cx, |_this, cx| { - cx.emit(CommunityEvent::Error(error.to_string())); - })?; - } - - Ok(()) - })); - } - } - - fn handle_notifications(&mut self, cx: &mut Context) { - self.notification_listener = None; - self.signal_consumer = None; - self.scheduler = None; - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let tx = self.signal_tx.clone(); - let rx = self.signal_rx.clone(); - let pages = self.pages.clone(); - let watches = self.watches.clone(); - let executor = cx.background_executor().clone(); - - self.notification_listener = Some(cx.background_spawn(async move { - let mut notifications = client.notifications(); - let mut batch = Batch::default(); - - 'outer: loop { - match notifications.next().await { - Some(notification) => { - if route(notification, &pages, &watches, &mut batch) == Flow::Stop { - flush(&tx, &mut batch).await?; - break 'outer; - } - } - None => break 'outer, - } - - let deadline = Instant::now() + PUMP_WINDOW; - - loop { - let now = Instant::now(); - - if now >= deadline { - break; - } - - let timer = executor.timer(deadline - now); - let next = notifications.next(); - futures::pin_mut!(timer); - futures::pin_mut!(next); - - match select(next, timer).await { - Either::Left((Some(notification), _)) => { - if route(notification, &pages, &watches, &mut batch) == Flow::Stop { - flush(&tx, &mut batch).await?; - break 'outer; - } - } - Either::Left((None, _)) => break 'outer, - Either::Right(_) => break, - } - } - - flush(&tx, &mut batch).await?; - } - - Ok(()) - })); - - self.signal_consumer = Some(cx.spawn(async move |this, cx| { - while let Ok(signal) = rx.recv_async().await { - match signal { - Signal::Event(id) => this.update(cx, |this, cx| { - // The only proof a community's subscription is still - // delivering anything. - this.last_event.insert(id, Instant::now()); - this.refresh(id, cx); - })?, - Signal::Rekey(id) => this.update(cx, |this, cx| this.rekey(id, cx))?, - Signal::List => this.update(cx, |this, cx| this.load(cx))?, - } - } - Ok(()) - })); - - self.scheduler = Some(cx.spawn(async move |this, cx| { - loop { - cx.background_executor().timer(MIN_ROUND_INTERVAL).await; - - if let Some(registry) = this.upgrade() { - registry.update(cx, |this, cx| { - this.tick(cx); - }); - } else { - break; - } - } - - Ok(()) - })); - } -} - -async fn subscribe( - client: &Client, - id: &SubscriptionId, - relays: &[RelayUrl], - filter: Filter, -) -> Result<()> { - client.unsubscribe(id).await?; - - if relays.is_empty() { - log::warn!("community {id}: no relay to subscribe to"); - return Ok(()); - } - - let mut targets: Vec<(RelayUrl, Vec)> = Vec::with_capacity(relays.len()); - - for url in relays { - if let Err(error) = client.add_relay(url).and_connect().await { - log::warn!("community {id}: failed to add relay {url}: {error}"); - } - - match client.relay(url).await { - Ok(Some(_)) => targets.push((url.clone(), vec![filter.clone()])), - Ok(None) => log::warn!("community {id}: relay {url} is not in the pool"), - Err(error) => log::warn!("community {id}: relay {url} could not be looked up: {error}"), - } - } - - if targets.is_empty() { - log::warn!("community {id}: no relay accepted the standing subscription"); - return Ok(()); - } - - let output = client - .subscribe(ReqTarget::manual(targets)) - .with_id(id.clone()) - .await?; - - if !output.failed.is_empty() { - log::warn!( - "community {id}: {} relay(s) rejected the subscription", - output.failed.len() - ); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn relay() -> RelayUrl { - RelayUrl::parse("wss://relay.example").expect("a url") - } - - fn event(subscription_id: SubscriptionId) -> ClientNotification { - let keys = Keys::generate(); - let event = EventBuilder::new(Kind::TextNote, "hi") - .finalize(&keys) - .expect("signs"); - - ClientNotification::Event { - relay_url: relay(), - subscription_id, - event: Box::new(event), - } - } - - fn message(message: RelayMessage<'static>) -> ClientNotification { - ClientNotification::Message { - relay_url: relay(), - message: Box::new(message), - } - } - - /// A burst within one window folds each community once, and the list once, - /// however many events arrived. - #[test] - fn a_burst_collapses_to_one_signal_per_community() { - let pages = PageRegistry::default(); - let watches = WatchRegistry::default(); - let community = CommunityId::from_bytes([0x42; 32]); - let mut batch = Batch::default(); - - let plane = event(sync::subscription_id(&community)); - for _ in 0..50 { - assert_eq!( - route(plane.clone(), &pages, &watches, &mut batch), - Flow::Continue - ); - } - - route( - event(sync::list_subscription_id()), - &pages, - &watches, - &mut batch, - ); - - // A page's event is folded from the database later, not routed here. - route( - event(SubscriptionId::new("concord-history-7")), - &pages, - &watches, - &mut batch, - ); - - assert!(batch.list); - assert_eq!(batch.communities, BTreeSet::from([community])); - assert!(batch.rekeys.is_empty()); - } - - /// A rekey watch's wraps put themselves in the database; the pump's only job - /// is to wake the adoption pass, and it resolves the community by id. - #[test] - fn a_rekey_watch_event_wakes_its_community() { - let pages = PageRegistry::default(); - let watches = WatchRegistry::default(); - let community = CommunityId::from_bytes([0x42; 32]); - let id = rekey::subscription_id(&community); - watches.register(id.clone(), community); - - let mut batch = Batch::default(); - route(event(id), &pages, &watches, &mut batch); - - assert_eq!(batch.rekeys, BTreeSet::from([community])); - assert!(batch.communities.is_empty()); - } - - #[test] - fn an_eose_settles_only_the_page_that_owns_the_id() { - let pages = PageRegistry::default(); - let mine = SubscriptionId::new("concord-history-1"); - let other = SubscriptionId::new("concord-history-2"); - let (mine_tx, mine_rx) = flume::bounded(1); - let (other_tx, other_rx) = flume::bounded(1); - pages.register(mine.clone(), mine_tx); - pages.register(other, other_tx); - - let mut batch = Batch::default(); - let flow = route( - message(RelayMessage::eose(mine)), - &pages, - &WatchRegistry::default(), - &mut batch, - ); - - assert_eq!(flow, Flow::Continue); - assert!(matches!( - mine_rx.try_recv().expect("a report").outcome, - Settled::Replayed - )); - assert!(other_rx.try_recv().is_err()); - } - - #[test] - fn a_refused_page_settles_its_relay_as_refused() { - let pages = PageRegistry::default(); - let id = SubscriptionId::new("concord-history-1"); - let (sender, receiver) = flume::bounded(1); - pages.register(id.clone(), sender); - - let mut batch = Batch::default(); - route( - message(RelayMessage::closed(id, "blocked: not allowed")), - &pages, - &WatchRegistry::default(), - &mut batch, - ); - - match receiver.try_recv().expect("a report").outcome { - Settled::Refused(reason) => assert!(reason.contains("blocked")), - other => panic!("expected a refusal, got {other:?}"), - } - } - - /// The SDK re-issues an `auth-required` REQ under the same id after AUTH, so - /// the page keeps waiting rather than writing the relay off. - #[test] - fn an_auth_required_close_settles_nothing() { - let pages = PageRegistry::default(); - let id = SubscriptionId::new("concord-history-1"); - let (sender, receiver) = flume::bounded(1); - pages.register(id.clone(), sender); - - let mut batch = Batch::default(); - route( - message(RelayMessage::closed( - id, - "auth-required: please authenticate", - )), - &pages, - &WatchRegistry::default(), - &mut batch, - ); - - assert!(receiver.try_recv().is_err()); - } - - #[test] - fn a_shutdown_stops_the_pump() { - let pages = PageRegistry::default(); - let mut batch = Batch::default(); - - assert_eq!( - route( - ClientNotification::Shutdown, - &pages, - &WatchRegistry::default(), - &mut batch - ), - Flow::Stop - ); - } -} diff --git a/crates/community/src/rekey.rs b/crates/community/src/rekey.rs deleted file mode 100644 index 42891241..00000000 --- a/crates/community/src/rekey.rs +++ /dev/null @@ -1,1211 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::sync::{Arc, Mutex, MutexGuard}; - -use anyhow::{Result, bail}; -use concord::cord01::{self, KIND_WRAP_EPHEMERAL, SealForm}; -use concord::cord04::roles::{CommunityRoles, Permissions}; -use concord::cord04::{self, AuthorityCitation}; -use concord::cord06::{ - self, Continuity, Refounding, RekeyScope, Rotation, RotationKey, RotationPlan, -}; -use concord::derive::{GroupKey, channel_rekey_group_key, epoch_key_commitment}; -use concord::state::{CommunityState, HeldKey}; -use concord::{ChannelId, CommunityId, Epoch}; -use nostr_sdk::prelude::*; - -use crate::sync::{self, PlaneKind}; - -/// Epochs ahead of a held epoch a rotation is looked for. -pub const REKEY_LOOKAHEAD: u64 = 8; -/// How much of what a relay stores a rekey watch replays. -const REKEY_REPLAY: usize = 200; - -/// One address the rekey watch asks a relay for, and what a wrap at it means. -#[derive(Debug, Clone)] -pub struct Watch { - pub address: PublicKey, - pub group: GroupKey, - pub scope: RekeyScope, - pub epoch: Epoch, -} - -/// The permission a rotation of `scope` is judged under, by whoever reads it and -/// by this client when it publishes one. -fn permissions(scope: RekeyScope) -> &'static [u64] { - match scope { - RekeyScope::Base => &[Permissions::BAN], - RekeyScope::Channel(_) => &[Permissions::MANAGE_CHANNELS, Permissions::BAN], - } -} - -/// Every address a community's rotations can arrive at. -/// -/// The base scope watches the epoch after *every* held root, not only the -/// current one: a rotation published while this client was away is read from -/// the root it stepped off, and the npub that minted an epoch is only knowable -/// from the rotation that minted it. -pub fn watches(state: &CommunityState) -> Result> { - let mut watches = Vec::new(); - let roots = state.roots(); - - for root in &roots { - let next = Epoch(root.epoch.0 + 1); - let group = cord06::rekey_group(RekeyScope::Base, &root.key, &state.id, next)?; - watches.push(Watch { - address: group.pk(), - group, - scope: RekeyScope::Base, - epoch: next, - }); - } - - for channel in &state.channels { - if !channel.private { - continue; - } - - for root in &roots { - for ahead in 1..=REKEY_LOOKAHEAD { - let epoch = Epoch(channel.epoch.0 + ahead); - let group = channel_rekey_group_key(&root.key, &channel.id, epoch)?; - watches.push(Watch { - address: group.pk(), - group, - scope: RekeyScope::Channel(channel.id), - epoch, - }); - } - } - } - - Ok(watches) -} - -pub fn watch_filter(watches: &[Watch]) -> Filter { - Filter::new() - .kind(Kind::GiftWrap) - .authors(watches.iter().map(|watch| watch.address)) - .limit(REKEY_REPLAY) -} - -/// The subscription carrying a community's rekey watch. -pub fn subscription_id(id: &CommunityId) -> SubscriptionId { - SubscriptionId::new(format!("rekey-{}", &id.to_hex()[..32])) -} - -/// Which community a rekey watch subscription belongs to. -#[derive(Default)] -pub struct WatchRegistry { - watches: Arc>>, -} - -impl Clone for WatchRegistry { - fn clone(&self) -> Self { - Self { - watches: Arc::clone(&self.watches), - } - } -} - -impl WatchRegistry { - pub fn register(&self, id: SubscriptionId, community: CommunityId) { - self.lock().insert(id, community); - } - - pub fn community_of(&self, id: &SubscriptionId) -> Option { - self.lock().get(id).copied() - } - - /// Forget every watch, because the account that installed them is gone. - pub fn clear(&self) { - self.lock().clear(); - } - - fn lock(&self) -> MutexGuard<'_, HashMap> { - match self.watches.lock() { - Ok(watches) => watches, - Err(poisoned) => poisoned.into_inner(), - } - } -} - -/// What one adoption pass learned, ready to be folded into the held state. -#[derive(Debug, Default)] -pub struct Adoptions { - pub base: Option, - pub channels: Vec, - /// Channels a complete rotation removed us from, with the epoch that did it. - pub cuts: Vec<(ChannelId, Epoch)>, - /// The base epoch a complete rotation excluded us at. - pub removed_at: Option, - pub stranded: bool, - /// The npubs whose rotations minted an epoch of this community. - pub refounders: BTreeSet, -} - -impl Adoptions { - pub fn is_empty(&self) -> bool { - self.base.is_none() - && self.channels.is_empty() - && self.cuts.is_empty() - && self.removed_at.is_none() - && !self.stranded - && self.refounders.is_empty() - } -} - -#[derive(Debug, Clone)] -pub struct BaseAdoption { - pub epoch: Epoch, - pub key: [u8; 32], - pub control_pk: Option, - /// The new Control Plane signing root, delivered to staff only. - pub control_root: Option<[u8; 32]>, - pub stepped: Vec, -} - -#[derive(Debug, Clone)] -pub struct ChannelAdoption { - pub channel: ChannelId, - pub epoch: Epoch, - pub key: [u8; 32], - /// The keys this rotation stepped off, newest first. - pub stepped: Vec, -} - -/// Read every rekey wrap the local database holds and adopt what is admissible. -pub async fn adopt( - client: &Client, - state: &CommunityState, - roles: &CommunityRoles, - signer: &S, - me: PublicKey, -) -> Result -where - S: AsyncNip44 + ?Sized, -{ - let watches = watches(state)?; - - let mut authors: BTreeSet = BTreeSet::new(); - for watch in &watches { - authors.insert(watch.address); - } - - if authors.is_empty() { - return Ok(Adoptions::default()); - } - - let wraps = client - .database() - .query(Filter::new().kind(Kind::GiftWrap).authors(authors)) - .await?; - - let mut chunks = Vec::new(); - let mut published: BTreeMap = BTreeMap::new(); - - for wrap in &wraps { - let Some(watch) = watches.iter().find(|watch| watch.address == wrap.pubkey) else { - continue; - }; - - let Ok(opened) = cord01::open_wrap(wrap, &watch.group) else { - continue; - }; - - let Ok(chunk) = cord06::parse_rekey_chunk(&opened) else { - continue; - }; - - if chunk.scope != watch.scope || chunk.new_epoch != watch.epoch { - continue; - } - - let at_ms = wrap.created_at.as_secs().saturating_mul(1000); - published - .entry(chunk.correlation()) - .and_modify(|held| *held = (*held).min(at_ms)) - .or_insert(at_ms); - - chunks.push(chunk); - } - - if chunks.is_empty() { - return Ok(Adoptions::default()); - } - - let rotations = cord06::collect_rotations(&chunks); - let mut adoptions = Adoptions::default(); - - let mut base = walk( - RekeyScope::Base, - permissions(RekeyScope::Base), - state.root_epoch, - state.community_root, - state, - roles, - signer, - me, - &rotations, - &published, - ) - .await?; - - let mut refounders: BTreeSet = base.refounders.clone(); - - for root in state.roots().into_iter().skip(1) { - let prior = walk( - RekeyScope::Base, - permissions(RekeyScope::Base), - root.epoch, - root.key, - state, - roles, - signer, - me, - &rotations, - &published, - ) - .await?; - - refounders.extend(prior.refounders.iter().copied()); - - let Some(adopted) = prior.adopted else { - continue; - }; - - if base - .adopted - .as_ref() - .is_none_or(|held| adopted.epoch > held.epoch) - { - base.adopted = Some(adopted); - } - } - - if let Some(step) = base.adopted { - adoptions.base = Some(BaseAdoption { - epoch: step.epoch, - key: step.key, - control_pk: step.control_pk, - control_root: step.control_root, - stepped: step.stepped, - }); - } - - adoptions.refounders = refounders; - adoptions.removed_at = base.removed_at; - adoptions.stranded = base.stranded; - - for channel in &state.channels { - if !channel.private { - continue; - } - - let Some((held_epoch, held_key)) = channel.current() else { - continue; - }; - - let step = walk( - RekeyScope::Channel(channel.id), - permissions(RekeyScope::Channel(channel.id)), - held_epoch, - held_key, - state, - roles, - signer, - me, - &rotations, - &published, - ) - .await?; - - if let Some(adopted) = step.adopted { - adoptions.channels.push(ChannelAdoption { - channel: channel.id, - epoch: adopted.epoch, - key: adopted.key, - stepped: adopted.stepped, - }); - } - - if let Some(epoch) = step.removed_at { - adoptions.cuts.push((channel.id, epoch)); - } - } - - Ok(adoptions) -} - -/// A rotation this client is asked to perform. -#[derive(Debug, Clone)] -pub struct Rewrite { - pub scope: RekeyScope, - /// Every member the new key must reach. - pub recipients: Vec, - /// The members it must not reach: whoever the rotation cuts off. - pub excluded: Vec, - /// The rotator's claim to rank, when it holds the grant to cite. - pub citation: Option, -} - -impl Rewrite { - /// Whether `rotator` may cut `excluded` off at all. - pub fn authorized( - &self, - roles: &CommunityRoles, - owner: &PublicKey, - rotator: &PublicKey, - ) -> bool { - permissions(self.scope) - .iter() - .any(|bits| cord06::rekey_authorized(roles, owner, rotator, *bits, &self.excluded)) - } -} - -/// Publish this client's own rotation of one scope to its next epoch. -#[allow(clippy::too_many_arguments)] -pub async fn rotate( - client: &Client, - state: &CommunityState, - roles: &CommunityRoles, - signer: &S, - me: PublicKey, - rewrite: &Rewrite, - at: Timestamp, -) -> Result -where - S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, -{ - if rewrite.excluded.contains(&me) { - bail!("a rotation cannot cut off the member who publishes it"); - } - - if !rewrite.recipients.contains(&me) { - bail!("a rotation must deliver its own rotator"); - } - - if rewrite - .excluded - .iter() - .any(|target| rewrite.recipients.contains(target)) - { - bail!("a rotation cannot both deliver to and cut off the same member"); - } - - let (held_epoch, held_key) = stepping_off(state, rewrite.scope)?; - let epoch = Epoch(held_epoch.0 + 1); - - if epoch.0 > cord06::MAX_REKEY_EPOCH { - bail!("epoch {} is past the rekey ceiling", epoch.0); - } - - let plan = cord06::plan_rotation(rewrite.scope, epoch)?; - let new_key = plan.new_key(); - let control_pk = match &plan { - RotationPlan::Base(refounding) => Some(refounding.signer(&state.id)?.pk().to_bytes()), - RotationPlan::Channel { .. } => None, - }; - - let mut blobs = Vec::with_capacity(rewrite.recipients.len()); - - for recipient in &rewrite.recipients { - // A refounded Control Plane's root reaches staff only. - let control_root = match &plan { - RotationPlan::Base(refounding) => roles - .is_staff(recipient, &state.owner) - .then_some(refounding.new_control_root), - RotationPlan::Channel { .. } => None, - }; - - blobs.push( - cord06::build_blob( - signer, - recipient, - rewrite.scope, - epoch, - &new_key, - control_pk.as_ref(), - control_root.as_ref(), - ) - .await?, - ); - } - - let group = cord06::rekey_group(rewrite.scope, &state.community_root, &state.id, epoch)?; - // A rotation that cuts somebody off is the severed kind. - let severed = !rewrite.excluded.is_empty(); - - let mut wraps = cord06::build_rekey_chunks( - signer, - &group, - rewrite.scope, - epoch, - held_epoch, - &epoch_key_commitment(held_epoch, &held_key), - &blobs, - rewrite.citation.as_ref(), - severed, - at.as_secs(), - ) - .await?; - - if let RotationPlan::Base(refounding) = &plan { - let carried = carry_heads(client, state, refounding, at).await?; - - if carried.is_empty() && !state.heads.is_empty() { - log::warn!("community: a refounding carried no settled head forward"); - } - - wraps.extend(carried); - } - - sync::publish_wraps(client, &wraps, &state.relays).await; - - log::debug!( - "community: rotated epoch {} to {} for {} member(s)", - held_epoch.0, - epoch.0, - blobs.len() - ); - - Ok(epoch) -} - -/// The epoch and key a rotation steps off. -fn stepping_off(state: &CommunityState, scope: RekeyScope) -> Result<(Epoch, [u8; 32])> { - match scope { - RekeyScope::Base => Ok((state.root_epoch, state.community_root)), - RekeyScope::Channel(channel) => state - .channels - .iter() - .find(|held| held.id == channel) - .and_then(|held| held.current()) - .ok_or_else(|| anyhow::anyhow!("no current key is held for {}", channel.to_hex())), - } -} - -/// Re-seal the settled control heads under a refounding's new groups. -/// -/// A rotation only mints keys. Unless the heads ride across with it, the new -/// epoch's Control Plane starts empty, and a member whose material never carried -/// the root we are stepping off folds no roles, metadata or banlist at all. -async fn carry_heads( - client: &Client, - state: &CommunityState, - refounding: &Refounding, - at: Timestamp, -) -> Result> { - if state.heads.is_empty() { - return Ok(Vec::new()); - } - - let planes = sync::planes(state)?; - let authors: BTreeSet = planes - .iter() - .filter(|plane| matches!(plane.kind, PlaneKind::Control(_))) - .map(|plane| plane.address) - .collect(); - - if authors.is_empty() { - return Ok(Vec::new()); - } - - let wraps = client - .database() - .query( - Filter::new() - .kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)]) - .authors(authors), - ) - .await?; - - let mut seals = Vec::new(); - let mut seen: BTreeSet = BTreeSet::new(); - - for wrap in &wraps { - let Some(plane) = planes.iter().find(|plane| { - matches!(plane.kind, PlaneKind::Control(_)) && plane.address == wrap.pubkey - }) else { - continue; - }; - - // A retired root reads only what was sealed before its rotation. - if !plane.accepts(wrap) { - continue; - } - - let Ok(opened) = - cord01::open_wrap_at(wrap, &plane.address, plane.group.conversation(), true) - else { - continue; - }; - - // Only a plaintext seal can be carried: `compact` re-signs nothing and - // re-wraps the edition the author already sealed. - if opened.seal_form != SealForm::Plaintext { - continue; - } - - let Ok(edition) = cord04::parse_edition(&opened.rumor) else { - continue; - }; - - let settled = state.heads.iter().any(|head| { - head.entity == edition.entity - && head.version == edition.version - && head.self_hash == edition.self_hash - }); - - // The same edition can be reachable twice once a head has been carried - // forward before: a head is published once per refounding. - if settled && seen.insert(opened.seal.id) { - seals.push(opened.seal); - } - } - - if seals.is_empty() { - return Ok(Vec::new()); - } - - Ok(cord06::compact( - &seals, - &refounding.read(&state.id)?, - &refounding.signer(&state.id)?, - at.as_secs(), - )?) -} - -/// The key grouping a rotation's chunks, recomputed from a collected rotation. -fn rotation_key(rotation: &Rotation) -> RotationKey { - ( - rotation.rotator.to_bytes(), - rotation.scope.id32(), - rotation.new_epoch.0, - rotation.prev_commit, - ) -} - -/// What one scope's walk found. -#[derive(Debug, Default)] -struct Step { - adopted: Option, - removed_at: Option, - stranded: bool, - /// The rotators of every base rotation this walk verified, which is who may - /// seed the Guestbook snapshot of an epoch they minted. - refounders: BTreeSet, -} - -#[derive(Debug, Clone)] -struct Adopted { - epoch: Epoch, - key: [u8; 32], - control_pk: Option, - control_root: Option<[u8; 32]>, - stepped: Vec, -} - -/// What one rotation offered this client, and when it was published. -#[derive(Debug, Clone, Copy)] -struct Delivery { - key: [u8; 32], - control_pk: Option, - control_root: Option<[u8; 32]>, - at_ms: u64, -} - -/// Walk a scope's rotations forward, one epoch at a time, off the key held. -#[allow(clippy::too_many_arguments)] -async fn walk( - scope: RekeyScope, - permissions: &[u64], - mut held_epoch: Epoch, - mut held_key: [u8; 32], - state: &CommunityState, - roles: &CommunityRoles, - signer: &S, - me: PublicKey, - rotations: &[Rotation], - published: &BTreeMap, -) -> Result -where - S: AsyncNip44 + ?Sized, -{ - let mut step = Step::default(); - let mut stepped: Vec = Vec::new(); - let ceiling = held_epoch.0 + REKEY_LOOKAHEAD; - - loop { - let target = Epoch(held_epoch.0 + 1); - - if target.0 > ceiling { - break; - } - - let candidates: Vec<&Rotation> = rotations - .iter() - .filter(|rotation| { - rotation.scope == scope - && rotation.new_epoch == target - && rotation.is_complete() - && !state.banned.contains(&rotation.rotator) - && permissions - .iter() - .any(|bits| roles.is_authorized(&rotation.rotator, &state.owner, *bits)) - }) - .collect(); - - if candidates.is_empty() { - break; - } - - let mut delivery: Option = None; - let mut addressed = false; - - for rotation in candidates - .iter() - .filter(|rotation| rotation.continuity(held_epoch, &held_key) == Continuity::Extends) - { - // Who minted an epoch is proven by the rotation itself, not by a blob: - // a rotation is only a candidate after continuity against a key we - // hold, so its rotator minted this epoch whether or not it addressed - // us. A member who joined on a stale bundle never held the epochs - // between, and the snapshot that seeds them is only honored on this - // npub's authority (CORD-02 §5). - if scope == RekeyScope::Base { - step.refounders.insert(rotation.rotator); - } - - let at_ms = published - .get(&rotation_key(rotation)) - .copied() - .unwrap_or_default(); - - let blobs = cord06::find_my_blobs( - &rotation.blobs, - &rotation.rotator, - &me, - scope, - rotation.new_epoch, - ) - .collect::>(); - - if blobs.is_empty() { - continue; - } - - addressed = true; - - for blob in blobs { - let Ok(delivered) = cord06::open_blob( - signer, - &rotation.rotator, - scope, - rotation.new_epoch, - blob, - &state.id, - ) - .await - else { - continue; - }; - - let control_pk = delivered - .control_pk - .and_then(|bytes| PublicKey::from_slice(&bytes).ok()); - let raced = delivery.map(|held| held.key); - - // Racing rotations converge on the lowest new key. - if raced.is_none_or(|raced| delivered.new_key < raced) { - delivery = Some(Delivery { - key: delivered.new_key, - control_pk, - control_root: delivered.control_root, - at_ms, - }); - } else if let Some(held) = delivery.as_mut() { - held.at_ms = held.at_ms.min(at_ms); - } - } - } - - if let Some(delivered) = delivery { - stepped.insert( - 0, - HeldKey { - epoch: held_epoch, - key: held_key, - retired_at: Some(Timestamp::from_secs(delivered.at_ms / 1000)), - }, - ); - - held_epoch = target; - held_key = delivered.key; - - step.adopted = Some(Adopted { - epoch: target, - key: delivered.key, - control_pk: delivered.control_pk, - control_root: delivered.control_root, - stepped: stepped.clone(), - }); - - continue; - } - - if addressed { - break; - } - - let judged: Vec<&&Rotation> = candidates - .iter() - .filter(|rotation| rotation.continuity(held_epoch, &held_key) != Continuity::Fork) - .collect(); - - let published_at = |rotation: &&Rotation| { - published - .get(&rotation_key(rotation)) - .copied() - .unwrap_or_default() - }; - - if judged.iter().any(|rotation| { - published_at(rotation) >= state.added_at_ms - && permissions.iter().any(|bits| { - roles.can_act_on_member(&rotation.rotator, &state.owner, &me, *bits) - }) - }) { - step.removed_at = Some(target); - } else if scope == RekeyScope::Base - && judged - .iter() - .any(|rotation| published_at(rotation) < state.added_at_ms) - { - step.stranded = true; - } - - break; - } - - Ok(step) -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use concord::cord04::roles::CommunityRoles; - use concord::cord06::{RekeyBlob, build_blob, build_rekey_chunks}; - use concord::derive::{ - base_rekey_group_key, channel_rekey_group_key, control_signer_group_key, - epoch_key_commitment, - }; - use concord::state::{ChannelKeyRef, HeldRoot}; - use nostr_memory::MemoryDatabase; - use state::UniversalSigner; - - use super::*; - - const AT_MS: u64 = 1_700_000_000_000; - const ROOT: [u8; 32] = [0x55; 32]; - const NEW_ROOT: [u8; 32] = [0x66; 32]; - const NEWER_ROOT: [u8; 32] = [0x77; 32]; - const CHANNEL_KEY: [u8; 32] = [0x07; 32]; - const NEW_CHANNEL_KEY: [u8; 32] = [0x08; 32]; - - fn client() -> Client { - ClientBuilder::default() - .database(MemoryDatabase::unbounded()) - .build() - } - - fn state(owner: PublicKey, id: CommunityId, channel: ChannelId) -> CommunityState { - CommunityState { - id, - name: Some("Room".to_owned()), - owner, - owner_salt: [0x01; 32], - community_root: ROOT, - root_epoch: Epoch(0), - control_root: None, - control_pks: BTreeMap::from([(0, owner)]), - channels: vec![ChannelKeyRef { - id: channel, - name: "staff".to_owned(), - private: true, - epoch: Epoch(0), - key: Some(CHANNEL_KEY), - priors: Vec::new(), - }], - relays: Vec::new(), - heads: Vec::new(), - banned: BTreeSet::new(), - cursors: BTreeMap::new(), - held_roots: Vec::new(), - channel_cuts: BTreeMap::new(), - refounders: BTreeSet::new(), - removed_at: None, - stranded: false, - dissolved: false, - added_at_ms: AT_MS, - } - } - - async fn store(client: &Client, wraps: &[Event]) { - for wrap in wraps { - client.database().save_event(wrap).await.expect("saves"); - } - } - - fn base_chunks( - owner: &Keys, - id: &CommunityId, - prior_commit: &[u8; 32], - blobs: &[RekeyBlob], - ) -> Vec { - let group = base_rekey_group_key(&ROOT, id, Epoch(1)).expect("derives"); - - smol::block_on(build_rekey_chunks( - owner, - &group, - RekeyScope::Base, - Epoch(1), - Epoch(0), - prior_commit, - blobs, - None, - false, - AT_MS / 1000, - )) - .expect("builds") - } - - fn channel_chunks( - owner: &Keys, - channel: &ChannelId, - prior_commit: &[u8; 32], - blobs: &[RekeyBlob], - ) -> Vec { - let scope = RekeyScope::Channel(*channel); - let group = channel_rekey_group_key(&ROOT, channel, Epoch(1)).expect("derives"); - - smol::block_on(build_rekey_chunks( - owner, - &group, - scope, - Epoch(1), - Epoch(0), - prior_commit, - blobs, - None, - false, - AT_MS / 1000, - )) - .expect("builds") - } - - fn blob_for( - rotator: &Keys, - recipient: &Keys, - scope: RekeyScope, - key: [u8; 32], - control_pk: Option<&[u8; 32]>, - ) -> RekeyBlob { - smol::block_on(build_blob( - rotator, - &recipient.public_key(), - scope, - Epoch(1), - &key, - control_pk, - None, - )) - .expect("builds") - } - - #[test] - fn a_complete_base_rotation_is_adopted_and_retires_the_prior_root() { - smol::block_on(async { - let client = client(); - let owner = Keys::generate(); - let me = Keys::generate(); - let id = CommunityId::from_bytes([0x42; 32]); - let channel = ChannelId::from_bytes([0x9c; 32]); - let state = state(owner.public_key(), id, channel); - - let control_root = [0xAB; 32]; - let control_pk = control_signer_group_key(&control_root, &id, Epoch(1)) - .expect("derives") - .pk() - .to_bytes(); - - let blob = blob_for(&owner, &me, RekeyScope::Base, NEW_ROOT, Some(&control_pk)); - let wraps = base_chunks(&owner, &id, &epoch_key_commitment(Epoch(0), &ROOT), &[blob]); - store(&client, &wraps).await; - - let signer = UniversalSigner::new(me.clone()); - let roles = CommunityRoles::default(); - let adoptions = adopt(&client, &state, &roles, &signer, me.public_key()) - .await - .expect("reads"); - - let base = adoptions.base.expect("adopted"); - assert_eq!(base.epoch, Epoch(1)); - assert_eq!(base.key, NEW_ROOT); - assert_eq!(base.control_pk, PublicKey::from_slice(&control_pk).ok()); - assert_eq!(base.stepped.len(), 1); - assert_eq!(base.stepped[0].epoch, Epoch(0)); - assert_eq!(base.stepped[0].key, ROOT); - assert_eq!( - base.stepped[0].retired_at, - Some(Timestamp::from_secs(AT_MS / 1000)) - ); - assert!(adoptions.removed_at.is_none()); - assert!(!adoptions.stranded); - }); - } - - /// A complete base rotation off a retained root is adopted from it, which - /// is the only way its epoch's refounder is ever learned: a Guestbook - /// snapshot seeds members on that npub's authority alone (CORD-02 §5). - #[test] - fn a_rotation_past_a_retained_root_is_adopted_and_names_its_refounder() { - smol::block_on(async { - let client = client(); - let owner = Keys::generate(); - let me = Keys::generate(); - let id = CommunityId::from_bytes([0x42; 32]); - let channel = ChannelId::from_bytes([0x9c; 32]); - - // At epoch 1, with the root it stepped off retained. - let mut state = state(owner.public_key(), id, channel); - state.root_epoch = Epoch(1); - state.community_root = NEW_ROOT; - state.held_roots = vec![HeldRoot { - epoch: Epoch(0), - key: ROOT, - control_pk: None, - retired_at: None, - }]; - - let blob = smol::block_on(build_blob( - &owner, - &me.public_key(), - RekeyScope::Base, - Epoch(2), - &NEWER_ROOT, - None, - None, - )) - .expect("builds"); - let group = base_rekey_group_key(&NEW_ROOT, &id, Epoch(2)).expect("derives"); - let wraps = smol::block_on(build_rekey_chunks( - &owner, - &group, - RekeyScope::Base, - Epoch(2), - Epoch(1), - &epoch_key_commitment(Epoch(1), &NEW_ROOT), - &[blob], - None, - false, - AT_MS / 1000, - )) - .expect("builds"); - store(&client, &wraps).await; - - let signer = UniversalSigner::new(me.clone()); - let adoptions = adopt( - &client, - &state, - &CommunityRoles::default(), - &signer, - me.public_key(), - ) - .await - .expect("reads"); - - let base = adoptions.base.expect("adopted"); - assert_eq!(base.epoch, Epoch(2)); - assert_eq!(base.key, NEWER_ROOT); - assert_eq!(base.stepped.len(), 1, "the root it stepped off"); - assert_eq!(base.stepped[0].epoch, Epoch(1)); - assert!(adoptions.refounders.contains(&owner.public_key())); - }); - } - - /// A rotation that addresses somebody else still names the npub who minted the - /// epoch: continuity against a key we hold is what proves it. That authority is - /// what a Guestbook snapshot is honored on, so a member who joined on a stale - /// bundle can still read the roster the Refounding seeded. - #[test] - fn a_rotation_that_delivered_us_no_key_still_names_its_refounder() { - smol::block_on(async { - let client = client(); - let owner = Keys::generate(); - let me = Keys::generate(); - let other = Keys::generate(); - let id = CommunityId::from_bytes([0x42; 32]); - let channel = ChannelId::from_bytes([0x9c; 32]); - - // At epoch 2, holding the root the rotation stepped off but never - // having walked it: the shape a stale join bundle produces. - let mut state = state(owner.public_key(), id, channel); - state.root_epoch = Epoch(2); - state.community_root = NEWER_ROOT; - state.held_roots = vec![HeldRoot { - epoch: Epoch(1), - key: NEW_ROOT, - control_pk: None, - retired_at: None, - }]; - - let blob = smol::block_on(build_blob( - &owner, - &other.public_key(), - RekeyScope::Base, - Epoch(2), - &NEWER_ROOT, - None, - None, - )) - .expect("builds"); - let group = base_rekey_group_key(&NEW_ROOT, &id, Epoch(2)).expect("derives"); - let wraps = smol::block_on(build_rekey_chunks( - &owner, - &group, - RekeyScope::Base, - Epoch(2), - Epoch(1), - &epoch_key_commitment(Epoch(1), &NEW_ROOT), - &[blob], - None, - false, - AT_MS / 1000, - )) - .expect("builds"); - store(&client, &wraps).await; - - let signer = UniversalSigner::new(me.clone()); - let adoptions = adopt( - &client, - &state, - &CommunityRoles::default(), - &signer, - me.public_key(), - ) - .await - .expect("reads"); - - assert!(adoptions.base.is_none(), "nothing to adopt"); - assert!(!adoptions.is_empty(), "the minter is still learned"); - assert!(adoptions.refounders.contains(&owner.public_key())); - }); - } - - /// A rotation off a key we do not hold is a fork: adoptable by nobody here. - #[test] - fn a_rotation_that_does_not_extend_the_held_key_is_never_adopted() { - smol::block_on(async { - let client = client(); - let owner = Keys::generate(); - let me = Keys::generate(); - let id = CommunityId::from_bytes([0x42; 32]); - let channel = ChannelId::from_bytes([0x9c; 32]); - let state = state(owner.public_key(), id, channel); - - let blob = blob_for(&owner, &me, RekeyScope::Base, NEW_ROOT, None); - let wraps = base_chunks( - &owner, - &id, - &epoch_key_commitment(Epoch(0), &[0x99; 32]), - &[blob], - ); - store(&client, &wraps).await; - - let signer = UniversalSigner::new(me.clone()); - let roles = CommunityRoles::default(); - let adoptions = adopt(&client, &state, &roles, &signer, me.public_key()) - .await - .expect("reads"); - - assert!(adoptions.is_empty()); - }); - } - - /// Every chunk held, none carrying my blob, from a rotator who outranks me - /// and published after I joined: I was excluded, not stranded. - #[test] - fn a_blobless_rotation_from_an_outranking_rotator_removes_the_member() { - smol::block_on(async { - let client = client(); - let owner = Keys::generate(); - let me = Keys::generate(); - let other = Keys::generate(); - let id = CommunityId::from_bytes([0x42; 32]); - let channel = ChannelId::from_bytes([0x9c; 32]); - let state = state(owner.public_key(), id, channel); - - let blob = blob_for(&owner, &other, RekeyScope::Base, NEW_ROOT, None); - let wraps = base_chunks(&owner, &id, &epoch_key_commitment(Epoch(0), &ROOT), &[blob]); - store(&client, &wraps).await; - - let signer = UniversalSigner::new(me.clone()); - let roles = CommunityRoles::default(); - let adoptions = adopt(&client, &state, &roles, &signer, me.public_key()) - .await - .expect("reads"); - - assert!(adoptions.base.is_none()); - assert_eq!(adoptions.removed_at, Some(Epoch(1))); - assert!(!adoptions.stranded); - }); - } - - #[test] - fn a_channel_rotation_replaces_the_key_and_keeps_the_prior() { - smol::block_on(async { - let client = client(); - let owner = Keys::generate(); - let me = Keys::generate(); - let id = CommunityId::from_bytes([0x42; 32]); - let channel = ChannelId::from_bytes([0x9c; 32]); - let state = state(owner.public_key(), id, channel); - - let blob = blob_for( - &owner, - &me, - RekeyScope::Channel(channel), - NEW_CHANNEL_KEY, - None, - ); - let wraps = channel_chunks( - &owner, - &channel, - &epoch_key_commitment(Epoch(0), &CHANNEL_KEY), - &[blob], - ); - store(&client, &wraps).await; - - let signer = UniversalSigner::new(me.clone()); - let roles = CommunityRoles::default(); - let adoptions = adopt(&client, &state, &roles, &signer, me.public_key()) - .await - .expect("reads"); - - assert_eq!(adoptions.channels.len(), 1); - let adopted = &adoptions.channels[0]; - assert_eq!(adopted.channel, channel); - assert_eq!(adopted.epoch, Epoch(1)); - assert_eq!(adopted.key, NEW_CHANNEL_KEY); - assert_eq!(adopted.stepped.len(), 1); - assert_eq!(adopted.stepped[0].epoch, Epoch(0)); - assert_eq!(adopted.stepped[0].key, CHANNEL_KEY); - assert_eq!( - adopted.stepped[0].retired_at, - Some(Timestamp::from_secs(AT_MS / 1000)) - ); - assert!(adoptions.cuts.is_empty()); - }); - } -} diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs deleted file mode 100644 index edfe79e5..00000000 --- a/crates/community/src/sync.rs +++ /dev/null @@ -1,1660 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::path::PathBuf; - -use anyhow::{Context, Result}; -use concord::cord01::KIND_WRAP_EPHEMERAL; -use concord::cord02::list::{CommunityList, JoinMaterial, KIND_COMMUNITY_LIST}; -use concord::cord02::{self, ControlFold, ImageRef}; -use concord::cord04::AuthorityCitation; -use concord::cord04::roles::{Permissions, citation_ok}; -use concord::derive::{ - channel_group_key, control_group_key, control_signer_group_key, guestbook_group_key, -}; -use concord::state::{CommunityState, HeldKey, HeldRoot, list_entry}; -use concord::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32}; -use gpui::AsyncApp; -use nostr_sdk::prelude::*; - -use crate::cache::{self, Observed}; -use crate::history::{CURSOR_OVERLAP, Window}; - -/// How much of what a relay stores a cold subscription replays per relay. -const LIVE_REPLAY: usize = 500; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum PlaneKind { - Control(Epoch), - Guestbook, - Channel(ChannelId, Epoch), -} - -#[derive(Debug, Clone)] -pub struct Plane { - pub kind: PlaneKind, - /// The wrap's author: the control signer for Control, the group's own key otherwise. - pub address: PublicKey, - pub group: GroupKey, - /// When the rotation that retired this plane's key published. - pub retired_at: Option, -} - -impl Plane { - /// Whether a wrap sealed under this plane is still inside its key's life. - pub fn accepts(&self, wrap: &Event) -> bool { - self.retired_at - .is_none_or(|retired| wrap.created_at <= retired) - } -} - -pub fn planes(state: &CommunityState) -> Result> { - let mut planes = Vec::new(); - let roots = state.roots(); - - for (epoch, address) in &state.control_pks { - let epoch = Epoch(*epoch); - // An epoch's Control Plane reads under the root that was current then, - // so a rotation that kept a floor for the prior root republishes here. - let root = roots - .iter() - .find(|root| root.epoch == epoch) - .copied() - .unwrap_or(HeldRoot { - epoch, - key: state.community_root, - control_pk: None, - retired_at: None, - }); - - let group = control_group_key(&root.key, &state.id, epoch)?; - planes.push(Plane { - kind: PlaneKind::Control(epoch), - address: *address, - group, - retired_at: root.retired_at, - }); - } - - if state.control_pks.is_empty() { - let group = control_group_key(&state.community_root, &state.id, state.root_epoch)?; - planes.push(Plane { - kind: PlaneKind::Control(state.root_epoch), - address: group.pk(), - group, - retired_at: None, - }); - } - - for root in &roots { - let group = guestbook_group_key(&root.key, &state.id, root.epoch)?; - planes.push(Plane { - kind: PlaneKind::Guestbook, - address: group.pk(), - group, - retired_at: root.retired_at, - }); - } - - for channel in &state.channels { - for held in state.held_keys(&channel.id) { - let group = channel_group_key(&held.key, &channel.id, held.epoch)?; - planes.push(Plane { - kind: PlaneKind::Channel(channel.id, held.epoch), - address: group.pk(), - group, - retired_at: held.retired_at, - }); - } - } - - // A rotation can re-derive an address the current root already produced. - // - // A duplicate author would only repeat a filter, so keep the set unique. - let mut seen = BTreeSet::new(); - planes.retain(|plane| seen.insert(plane.address)); - - Ok(planes) -} - -pub fn plane_filter(planes: &[Plane]) -> Filter { - Filter::new() - .kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)]) - .authors(planes.iter().map(|plane| plane.address)) -} - -pub fn live_filter(planes: &[Plane], window: Window) -> Filter { - let mut filter = plane_filter(planes); - - if let Some(until) = window.until { - filter = filter.until(until); - } - - if let Some(since) = window.since { - filter = filter.since(since); - } - - filter.limit(LIVE_REPLAY) -} - -/// The window a community's standing subscription opens with. -pub fn live_window(state: &CommunityState, now: Timestamp) -> Window { - let floor = state - .channels - .iter() - .filter_map(|channel| state.cursors.get(&channel.id)?.newest) - .min(); - - match floor { - Some(floor) => Window { - since: Some(floor.min(now) - CURSOR_OVERLAP), - until: None, - }, - None => Window::default(), - } -} - -/// The subscription id carrying a community's planes. -pub fn subscription_id(id: &CommunityId) -> SubscriptionId { - SubscriptionId::new(id.to_hex()) -} - -pub fn community_of(subscription_id: &SubscriptionId) -> Option { - subscription_id.as_str().parse().ok() -} - -/// Download and decrypt a community icon into a content-addressed cache file. -pub async fn resolve_image(image: &ImageRef, cx: &AsyncApp) -> Result { - let url = Url::parse(&image.url).context("community image url")?; - state::download_and_decrypt_to_cache(&url, &image.key, &image.nonce, &image.hash, cx).await -} - -#[derive(Debug, Clone)] -pub struct Snapshot { - pub state: CommunityState, - pub control: ControlFold, - pub members: BTreeSet, - /// Wraps the store holds per channel that no held key can open. - pub unreadable: BTreeMap, -} - -pub async fn create( - client: &Client, - signer: &S, - metadata: &cord02::CommunityMetadata, -) -> Result -where - S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, -{ - let at_secs = Timestamp::now().as_secs(); - let genesis = cord02::genesis(signer, metadata, at_secs).await?; - let id = genesis.identity.community_id; - - let read = control_group_key(&genesis.community_root, &id, cord02::ROOT_EPOCH)?; - let address = control_signer_group_key(&genesis.control_root, &id, cord02::ROOT_EPOCH)?.pk(); - - let mut editions = Vec::with_capacity(genesis.wraps.len()); - - for wrap in &genesis.wraps { - editions.push(cord02::open_edition(wrap, &read, &address, true)?); - } - - let state = CommunityState::from_genesis(&genesis, &editions, at_secs.saturating_mul(1000))?; - cache::save_state(client, &state).await?; - - publish_wraps(client, &genesis.wraps, &state.relays).await; - - if let Err(error) = record_membership(client, signer, &state, &metadata.name).await { - log::warn!( - "community {}: recording the membership failed: {error}", - state.id.to_hex() - ); - } - - Ok(state) -} - -/// Best-effort publication of the genesis wraps to the community's relays. -pub(crate) async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) { - connect_relays(client, relays).await; - - for wrap in wraps { - publish_wrap(client, wrap, relays).await; - } -} - -/// Bring the community's relays into the pool before anything is sent through them. -pub(crate) async fn connect_relays(client: &Client, relays: &[RelayUrl]) { - for url in relays { - if let Err(error) = client.add_relay(url).and_connect().await { - log::warn!("community: failed to add relay {url}: {error}"); - } - } -} - -/// Best-effort publication of a single wrap to the community's relays. -pub(crate) async fn publish_wrap(client: &Client, wrap: &Event, relays: &[RelayUrl]) { - let sent = if relays.is_empty() { - client.send_event(wrap).broadcast().await - } else { - client.send_event(wrap).to(relays.iter().cloned()).await - }; - - match sent { - Ok(output) if output.failed.is_empty() => {} - Ok(output) => log::warn!( - "community: {} relay(s) rejected {}", - output.failed.len(), - wrap.id - ), - Err(error) => log::warn!("community: publishing {} failed: {error}", wrap.id), - } -} - -async fn record_membership( - client: &Client, - signer: &S, - state: &CommunityState, - name: &str, -) -> Result<()> -where - S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, -{ - let self_pk = signer.get_public_key_async().await?; - let held = load_list(client, signer, self_pk).await?; - - let frags = held.as_ref().map_or(1, |list| list.frags); - - if frags > 1 { - log::warn!( - "community {}: the list spans {frags} fragments; deferring the membership write", - state.id.to_hex() - ); - return Ok(()); - } - - let entry = list_entry(state, name); - let list = match held { - Some(held) => held.joined(entry), - None => CommunityList::default().joined(entry), - }; - - let previous = newest_fragment_at(client, self_pk).await?; - let now = Timestamp::now().as_secs(); - let at_secs = previous.map_or(now, |previous| now.max(previous.as_secs() + 1)); - - let event = cord02::list::build_list_event(signer, &list, 0, at_secs).await?; - publish_list(client, &event).await; - - Ok(()) -} - -async fn publish_list(client: &Client, event: &Event) { - match client.send_event(event).to_nip65().await { - Ok(output) if output.failed.is_empty() => {} - Ok(output) => log::warn!( - "community list: {} relay(s) rejected the publish", - output.failed.len() - ), - Err(error) => log::warn!("community list: publish failed: {error}"), - } -} - -/// The newest `created_at` the account holds across its list fragments. -async fn newest_fragment_at(client: &Client, self_pk: PublicKey) -> Result> { - Ok(newest_fragments(client, self_pk) - .await? - .into_values() - .map(|event| event.created_at) - .max()) -} - -/// The subscription id carrying the account's own Community List. -pub const LIST_SUBSCRIPTION: &str = "concord/list"; - -pub fn list_subscription_id() -> SubscriptionId { - SubscriptionId::new(LIST_SUBSCRIPTION) -} - -pub fn is_list_subscription(id: &SubscriptionId) -> bool { - id.as_str() == LIST_SUBSCRIPTION -} - -/// Subscribes to the account's community list. -pub async fn subscribe_list(client: &Client, self_pk: PublicKey) -> Result<()> { - let id = list_subscription_id(); - client.unsubscribe(&id).await?; - - let filter = Filter::new() - .kind(Kind::Custom(KIND_COMMUNITY_LIST)) - .author(self_pk); - - let output = client - .subscribe(ReqTarget::auto(vec![filter])) - .with_id(id) - .await?; - - if !output.failed.is_empty() { - log::warn!( - "community list: {} relay(s) rejected the subscription", - output.failed.len() - ); - } - - Ok(()) -} - -/// Discovers the current account's communities: every live membership the List -/// carries, plus any locally-held membership the List does not mention. -/// -/// A held membership is dropped only when the List carries a tombstone at least -/// as new as it, because absence from the List is never a fact (§8). -pub async fn load(client: &Client, signer: &S, self_pk: PublicKey) -> Result> -where - S: AsyncGetPublicKey + AsyncNip44 + ?Sized, -{ - let list = match load_list(client, signer, self_pk).await? { - Some(list) => list, - None => return cache::load_states(client).await, - }; - - let mut held: BTreeMap = cache::load_states(client) - .await? - .into_iter() - .map(|state| (state.id, state)) - .collect(); - - held.retain(|id, state| !retired(&list, id, state.added_at_ms)); - - for entry in &list.entries { - if !list.is_live(&entry.community_id) { - continue; - } - - let fresh = match CommunityState::from_join_material(&entry.current, entry.added_at) { - Ok(fresh) => fresh, - Err(error) => { - log::warn!( - "ignoring unreadable community {} from the list: {error}", - entry.community_id.to_hex() - ); - continue; - } - }; - - let mut state = match held.remove(&entry.community_id) { - Some(materialized) => refresh(materialized, fresh), - None => fresh, - }; - - retain_join_root(&mut state, &entry.seed); - adopt_list_material(&mut state, &entry.seed); - adopt_list_material(&mut state, &entry.current); - - cache::save_state(client, &state).await?; - held.insert(entry.community_id, state); - } - - Ok(held.into_values().collect()) -} - -/// Take the snapshot authority and retained roots a List entry names. -fn adopt_list_material(state: &mut CommunityState, material: &JoinMaterial) { - if material.root_epoch.0 > 0 - && let Some(refounder) = material.refounder() - { - state.refounders.insert(refounder); - } - - for root in material.held_roots() { - if root.epoch >= state.root_epoch || root.epoch.0 == 0 { - continue; - } - - if let Some(refounder) = root.refounder { - state.refounders.insert(refounder); - } - - retain_root(state, root.epoch, root.key, root.control_pk); - } -} - -fn retain_join_root(state: &mut CommunityState, seed: &JoinMaterial) { - if seed.root_epoch >= state.root_epoch { - return; - } - - let Ok(key) = decode_hex_32(&seed.community_root) else { - return; - }; - - retain_root(state, seed.root_epoch, key, seed.control_pk); -} - -/// Retain a root the community has rotated past, newest first. -fn retain_root( - state: &mut CommunityState, - epoch: Epoch, - key: [u8; 32], - control_pk: Option, -) { - if state.held_roots.iter().any(|root| root.epoch == epoch) { - return; - } - - let at = state - .held_roots - .iter() - .position(|root| root.epoch < epoch) - .unwrap_or(state.held_roots.len()); - - state.held_roots.insert( - at, - HeldRoot { - epoch, - key, - control_pk, - retired_at: None, - }, - ); -} - -fn retired(list: &CommunityList, id: &CommunityId, added_at_ms: u64) -> bool { - list.tombstones - .iter() - .find(|tombstone| tombstone.community_id == *id) - .is_some_and(|tombstone| tombstone.removed_at >= added_at_ms) -} - -fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState { - if fresh.root_epoch > held.root_epoch { - let (epoch, key) = (held.root_epoch, held.community_root); - let control_pk = held.control_pks.get(&epoch.0).copied(); - retain_root(&mut held, epoch, key, control_pk); - } - - held.owner = fresh.owner; - held.owner_salt = fresh.owner_salt; - held.community_root = fresh.community_root; - held.root_epoch = fresh.root_epoch; - held.added_at_ms = fresh.added_at_ms; - - if fresh.control_root.is_some() { - held.control_root = fresh.control_root; - } - - if let Some(name) = fresh.name { - held.name = Some(name); - } - - for (epoch, address) in fresh.control_pks { - held.control_pks.insert(epoch, address); - } - - held.relays = fresh.relays; - - for channel in fresh.channels { - let cut = held.channel_cuts.get(&channel.id).copied(); - - match held.channels.iter_mut().find(|held| held.id == channel.id) { - Some(held) => { - held.name = channel.name; - - if cut.is_some_and(|cut| channel.epoch <= cut) { - continue; - } - - if channel.private { - held.private = true; - - if let Some(key) = channel.key - && (held.key != Some(key) || held.epoch != channel.epoch) - { - // The key being superseded still reads everything - // written under it, so it is retained, never overwritten. - if let Some((epoch, previous)) = held.current() - && !held.priors.iter().any(|prior| prior.epoch == epoch) - { - held.priors.push(HeldKey { - epoch, - key: previous, - retired_at: None, - }); - } - - held.key = Some(key); - held.epoch = channel.epoch; - } - } - } - None => { - if cut.is_none() { - held.channels.push(channel); - } - } - } - } - - held -} - -/// The newest held copy of each fragment, keyed by its `d` index. -async fn newest_fragments(client: &Client, self_pk: PublicKey) -> Result> { - let filter = Filter::new() - .kind(Kind::Custom(KIND_COMMUNITY_LIST)) - .author(self_pk); - - let mut newest: BTreeMap = BTreeMap::new(); - - for event in client.database().query(filter).await? { - let Ok(index) = cord02::list::fragment_index(&event) else { - continue; - }; - - match newest.get(&index) { - Some(existing) if existing.created_at >= event.created_at => {} - _ => { - newest.insert(index, event); - } - } - } - - Ok(newest) -} - -/// Every fragment of the account's list in the local database, merged. -async fn load_list( - client: &Client, - signer: &S, - self_pk: PublicKey, -) -> Result> -where - S: AsyncGetPublicKey + AsyncNip44 + ?Sized, -{ - let mut merged: Option = None; - - for event in newest_fragments(client, self_pk).await?.into_values() { - match cord02::list::parse_list_event(signer, &event).await { - Ok(list) => { - merged = Some(match merged { - Some(held) => cord02::list::merge(held, list), - None => list, - }); - } - Err(error) => { - log::warn!("ignoring unreadable community list {}: {error}", event.id); - } - } - } - - Ok(merged) -} - -/// Rebuilds a community from the wraps already in the local database. -pub async fn fold(client: &Client, state: &CommunityState) -> Result> { - let planes = planes(state)?; - - if planes.is_empty() { - return Ok(None); - } - - let wraps = client.database().query(plane_filter(&planes)).await?; - - let mut editions = Vec::new(); - let mut observed: BTreeMap = BTreeMap::new(); - let mut guestbook_rumors = Vec::new(); - let mut unreadable: BTreeMap = BTreeMap::new(); - let mut cached: BTreeMap> = BTreeMap::new(); - - for plane in &planes { - if let PlaneKind::Channel(channel, _) = plane.kind - && !cached.contains_key(&channel) - { - cached.insert(channel, cache::wrapper_index(client, &channel).await?); - } - } - - for wrap in &wraps { - let Some(plane) = planes.iter().find(|plane| plane.address == wrap.pubkey) else { - continue; - }; - - match plane.kind { - PlaneKind::Control(_) => { - // A retired root reads only what was sealed before its rotation. - if !plane.accepts(wrap) { - continue; - } - - if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true) - { - observe( - &mut observed, - edition.author, - wrap.created_at.as_secs().saturating_mul(1000), - ); - editions.push(edition); - } - } - PlaneKind::Guestbook => { - if !plane.accepts(wrap) { - continue; - } - - if let Ok((_, rumor)) = cord02::guestbook::open(wrap, &plane.group) { - observe(&mut observed, rumor.author, rumor.at_ms); - guestbook_rumors.push(rumor); - } - } - PlaneKind::Channel(channel, epoch) => { - if let Some(row) = cached.get(&channel).and_then(|index| index.get(&wrap.id)) { - observe(&mut observed, row.author, row.at_ms); - continue; - } - - let opened = if plane.accepts(wrap) { - concord::cord03::open(wrap, &plane.group, &channel, epoch).ok() - } else { - None - }; - - match opened { - Some((opened, rumor)) => { - cache::cache_rumor(client, &channel, &opened).await?; - observe(&mut observed, rumor.author, rumor.at_ms); - } - None => *unreadable.entry(channel).or_default() += 1, - } - } - } - } - - if editions.is_empty() { - return Ok(None); - } - - let control = cord02::fold_control( - &state.owner, - &state.id, - &editions, - &state.floors(), - &state.banned, - ); - - let granted: BTreeSet = control - .roles - .grants() - .filter(|grant| !grant.role_ids.is_empty()) - .map(|grant| grant.member) - .collect(); - - let floors = state.floors(); - let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| { - citation_ok(&state.owner, &state.id, actor, citation, &floors) - && control - .roles - .can_act_on_member(actor, &state.owner, target, Permissions::KICK) - }; - - let now_ms = Timestamp::now().as_secs().saturating_mul(1000); - let coalesced = - cord02::guestbook::coalesce(&guestbook_rumors, now_ms, &state.refounders, can_kick); - - let mut members = cord02::guestbook::complete_memberlist( - &coalesced, - &observed, - &granted, - &control.banned, - &BTreeMap::new(), - ); - - // The roster has no grant for the owner, so membership is stated here. - members.insert(state.owner); - - let mut state = state.clone(); - state.apply_fold(&control); - cache::save_state(client, &state).await?; - - Ok(Some(Snapshot { - state, - control, - members, - unreadable, - })) -} - -fn observe(observed: &mut BTreeMap, author: PublicKey, at_ms: u64) { - observed - .entry(author) - .and_modify(|seen| *seen = (*seen).max(at_ms)) - .or_insert(at_ms); -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use nostr_memory::MemoryDatabase; - use state::UniversalSigner; - - use super::*; - - fn client() -> Client { - ClientBuilder::default() - .database(MemoryDatabase::unbounded()) - .build() - } - - #[test] - fn planes_address_the_control_guestbook_and_every_readable_channel() { - let owner = Keys::generate().public_key(); - let control_pk = Keys::generate().public_key(); - let general = ChannelId::from_bytes([0x9c; 32]); - let staff = ChannelId::from_bytes([0x9d; 32]); - - let state = CommunityState { - id: CommunityId::from_bytes([0x42; 32]), - name: Some("Anime and Manga".to_owned()), - owner, - owner_salt: [0x01; 32], - community_root: [0x02; 32], - root_epoch: Epoch(0), - control_root: None, - control_pks: BTreeMap::from([(0, control_pk)]), - channels: vec![ - concord::state::ChannelKeyRef { - id: general, - name: "general".to_owned(), - private: false, - epoch: Epoch(0), - key: None, - priors: Vec::new(), - }, - concord::state::ChannelKeyRef { - id: staff, - name: "staff".to_owned(), - private: true, - epoch: Epoch(0), - key: Some([0x04; 32]), - priors: Vec::new(), - }, - concord::state::ChannelKeyRef { - id: ChannelId::from_bytes([0x9e; 32]), - name: "locked".to_owned(), - private: true, - epoch: Epoch(0), - key: None, - priors: Vec::new(), - }, - ], - relays: vec![RelayUrl::parse("wss://relay.example").expect("a url")], - heads: Vec::new(), - banned: BTreeSet::new(), - cursors: BTreeMap::new(), - held_roots: Vec::new(), - channel_cuts: BTreeMap::new(), - refounders: BTreeSet::new(), - removed_at: None, - stranded: false, - dissolved: false, - added_at_ms: 0, - }; - - let planes = planes(&state).expect("planes"); - - assert_eq!(planes.len(), 4); - assert!(planes.iter().any(|plane| plane.address == control_pk)); - assert!( - planes - .iter() - .any(|plane| matches!(plane.kind, PlaneKind::Guestbook)) - ); - assert!( - planes - .iter() - .any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == general)) - ); - assert!( - planes - .iter() - .any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == staff)), - "a private channel whose key is held is subscribed" - ); - - let filter = plane_filter(&planes); - let addresses: BTreeSet = planes.iter().map(|plane| plane.address).collect(); - assert_eq!(filter.authors, Some(addresses)); - assert_eq!( - filter.kinds, - Some(BTreeSet::from([ - Kind::GiftWrap, - Kind::Custom(KIND_WRAP_EPHEMERAL) - ])), - "the standing subscription asks for both wrap kinds" - ); - } - - /// A cold subscription asks wide; a warm one resumes at the oldest held - /// cursor, minus the overlap, so no channel's new region is skipped. A floor - /// the local clock has not reached is a peer's stamp and is clamped, or the - /// subscription would ask from a region that is still in the future. - #[test] - fn the_live_window_is_wide_cold_and_resumes_at_the_oldest_cursor_warm() { - let mut state = held( - CommunityId::from_bytes([0x42; 32]), - Keys::generate().public_key(), - ); - let channel = state.channels[0].id; - let now = Timestamp::now(); - - assert_eq!(live_window(&state, now), Window::default()); - - state.cursors.insert( - channel, - concord::state::ChannelCursor { - newest: Some(Timestamp::from_secs(2_000_000)), - oldest: Some(Timestamp::from_secs(1_000)), - exhausted: false, - }, - ); - assert_eq!( - live_window(&state, now), - Window { - since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP), - until: None, - } - ); - - let ahead = now + Duration::from_secs(3_600); - state.cursors.insert( - channel, - concord::state::ChannelCursor { - newest: Some(ahead), - oldest: Some(Timestamp::from_secs(1_000)), - exhausted: false, - }, - ); - assert_eq!( - live_window(&state, now), - Window { - since: Some(now - CURSOR_OVERLAP), - until: None, - }, - "a cursor stamped in the future must not open the REQ ahead of now" - ); - } - - fn metadata(name: &str) -> cord02::CommunityMetadata { - cord02::CommunityMetadata { - name: name.to_owned(), - ..cord02::CommunityMetadata::default() - } - } - - fn held(id: CommunityId, control_pk: PublicKey) -> CommunityState { - CommunityState { - id, - name: Some("Anime and Manga".to_owned()), - owner: Keys::generate().public_key(), - owner_salt: [0x01; 32], - community_root: [0x02; 32], - root_epoch: Epoch(0), - control_root: Some([0x03; 32]), - control_pks: BTreeMap::from([(0, control_pk)]), - channels: vec![concord::state::ChannelKeyRef { - id: ChannelId::from_bytes([0x9c; 32]), - name: "general".to_owned(), - private: false, - epoch: Epoch(0), - key: None, - priors: Vec::new(), - }], - relays: Vec::new(), - heads: Vec::new(), - banned: BTreeSet::new(), - cursors: BTreeMap::new(), - held_roots: Vec::new(), - channel_cuts: BTreeMap::new(), - refounders: BTreeSet::new(), - removed_at: None, - stranded: false, - dissolved: false, - added_at_ms: 1_700_000_000_000, - } - } - - /// Puts a List in the database as the account's own fragment, exactly as a - /// relay would have delivered it. - async fn store_fragment(client: &Client, signer: &S, list: &CommunityList) - where - S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, - { - let event = cord02::list::build_list_event(signer, list, 0, 1_700_000_000) - .await - .expect("builds"); - client.database().save_event(&event).await.expect("saves"); - } - - /// A Refounding snapshot seeds members this client has never seen publish — - /// but only on the authority of the npub whose rotation minted the epoch it - /// seeds (CORD-02 §5), which is why the refounder is recorded at all. - #[test] - fn a_refounders_snapshot_seeds_the_members_it_names() { - smol::block_on(async { - let client = client(); - let keys = Keys::generate(); - let signer = UniversalSigner::new(keys.clone()); - let refounder = Keys::generate(); - let quiet = Keys::generate().public_key(); - - let created = create(&client, &signer, &metadata("coop")) - .await - .expect("creates"); - - // Past genesis, and past the rotation whose refounder this client - // verified: genesis mints no epoch by rotation, so it has no - // snapshot authority at all. - let mut state = created; - state.root_epoch = Epoch(1); - state.refounders.insert(refounder.public_key()); - - let group = guestbook_group_key(&state.community_root, &state.id, state.root_epoch) - .expect("a guestbook plane"); - let chunks = cord02::guestbook::build_snapshot_chunks( - refounder.public_key(), - &[quiet], - [0x77u8; 32], - state.added_at_ms, - ); - - for chunk in &chunks { - let (wrap, _) = cord02::guestbook::seal_rumor(chunk, &group, &refounder) - .await - .expect("seals"); - client.database().save_event(&wrap).await.expect("saves"); - } - - let seeded = fold(&client, &state) - .await - .expect("folds") - .expect("a control plane"); - assert!( - seeded.members.contains(&quiet), - "the snapshot's members are the roster" - ); - - // Without the rotation that minted the epoch, the same chunks seed - // nobody: an unverifiable snapshot is not an authority. - let mut unverified = state.clone(); - unverified.refounders.clear(); - - let folded = fold(&client, &unverified) - .await - .expect("folds") - .expect("a control plane"); - assert!(!folded.members.contains(&quiet)); - }); - } - - /// A List entry that names the npub whose Refounding minted its epoch hands - /// this client the authority its Guestbook snapshot is honored on, which is - /// how a device that never held the rotation still reads the seeded roster. - /// (What the fold then does with that authority is the neighboring test.) - #[test] - fn a_list_entrys_refounder_becomes_the_snapshot_authority() { - smol::block_on(async { - let client = client(); - let keys = Keys::generate(); - let signer = UniversalSigner::new(keys.clone()); - let refounder = Keys::generate(); - - let joined = held( - CommunityId::from_bytes([0x42; 32]), - Keys::generate().public_key(), - ); - let mut rotated = joined.clone(); - rotated.root_epoch = Epoch(2); - rotated.community_root = [0x44; 32]; - - let mut entry = list_entry(&rotated, "coop"); - entry.seed = list_entry(&joined, "coop").seed; - entry.added_at = joined.added_at_ms; - entry.current.extra.insert( - "refounder".to_owned(), - serde_json::Value::String(refounder.public_key().to_hex()), - ); - - let list = CommunityList::default().joined(entry); - store_fragment(&client, &signer, &list).await; - - let loaded = load(&client, &signer, keys.public_key()) - .await - .expect("loads"); - let state = loaded - .iter() - .find(|state| state.id == joined.id) - .expect("loaded"); - - assert_eq!(state.root_epoch, Epoch(2)); - assert!(state.refounders.contains(&refounder.public_key())); - }); - } - - /// A List that has rotated on keeps the root of our join: the material is - /// the community as we were given it, and the planes that root addressed - /// stay readable only while it is held. - #[test] - fn a_list_that_moved_the_root_on_retains_the_root_of_our_join() { - smol::block_on(async { - let client = client(); - let keys = Keys::generate(); - let signer = UniversalSigner::new(keys.clone()); - - let joined = held( - CommunityId::from_bytes([0x42; 32]), - Keys::generate().public_key(), - ); - let mut rotated = joined.clone(); - rotated.root_epoch = Epoch(2); - rotated.community_root = [0x44; 32]; - - // The entry as a community that rotated twice since would carry it: - // the join's material as the seed, the current material as current. - let mut entry = list_entry(&rotated, "coop"); - entry.seed = list_entry(&joined, "coop").seed; - entry.added_at = joined.added_at_ms; - - let list = CommunityList::default().joined(entry); - store_fragment(&client, &signer, &list).await; - - let loaded = load(&client, &signer, keys.public_key()) - .await - .expect("loads"); - let state = loaded - .iter() - .find(|state| state.id == joined.id) - .expect("loaded"); - - assert_eq!(state.root_epoch, Epoch(2)); - assert_eq!(state.community_root, [0x44; 32]); - assert_eq!( - state - .held_roots - .iter() - .map(|root| (root.epoch, root.key)) - .collect::>(), - vec![(joined.root_epoch, joined.community_root)] - ); - }); - } - - /// What `CommunityRegistry` needs from a created community: a state document - /// `load` finds, a control plane the subscription filter actually addresses, - /// and a fold that survives an inbound control edit. - #[test] - fn creating_a_community_persists_a_state_that_subscribes_and_folds() { - smol::block_on(async { - let client = client(); - let keys = Keys::generate(); - let signer = UniversalSigner::new(keys.clone()); - - let created = create(&client, &signer, &metadata("coop")) - .await - .expect("creates"); - - let loaded = load(&client, &signer, keys.public_key()) - .await - .expect("loads"); - assert_eq!(loaded, vec![created.clone()]); - - // The plane filter must address the genesis wraps, or a fold would - // read a plane nothing is ever published on. - let planes = planes(&created).expect("planes"); - let wraps = client - .database() - .query(plane_filter(&planes)) - .await - .expect("queries"); - assert_eq!(wraps.len(), created.heads.len()); - assert!(wraps.iter().all(|wrap| wrap.kind == Kind::GiftWrap)); - - let snapshot = fold(&client, &created) - .await - .expect("folds") - .expect("a control plane"); - assert_eq!(snapshot.state.channels.len(), 1); - assert_eq!(snapshot.members, BTreeSet::from([keys.public_key()])); - assert_eq!( - snapshot - .control - .community - .as_ref() - .map(|metadata| metadata.name.as_str()), - Some("coop") - ); - - // An inbound control edit made by the owner folds over the created state. - let community_head = created - .heads - .iter() - .find(|head| head.entity == *created.id.as_bytes()) - .expect("a community head"); - let writer = cord02::ControlWriter { - author: created.owner, - read: control_group_key(&created.community_root, &created.id, cord02::ROOT_EPOCH) - .expect("a reading key"), - signer: control_signer_group_key( - &created.control_root.expect("a control root"), - &created.id, - cord02::ROOT_EPOCH, - ) - .expect("a signing key"), - }; - - let (wrap, _) = writer - .set_community_metadata( - &keys, - &created.id, - &metadata("coop two"), - Some(community_head), - None, - Timestamp::now().as_secs() + 1, - ) - .await - .expect("publishes"); - client.database().save_event(&wrap).await.expect("saves"); - - let updated = fold(&client, &created) - .await - .expect("folds") - .expect("a control plane"); - assert_eq!( - updated - .control - .community - .as_ref() - .map(|metadata| metadata.name.as_str()), - Some("coop two") - ); - }); - } - - /// The reference counts anyone seen publishing anywhere, and a control edit - /// is the one publication that leaves no other trace: an npub that has - /// never joined a channel and never touched the Guestbook exists in no - /// other plane, so a fold that reads only those reads them as a stranger. - #[test] - fn a_control_editions_author_is_observed_as_a_member() { - smol::block_on(async { - let client = client(); - let keys = Keys::generate(); - let signer = UniversalSigner::new(keys.clone()); - let editor = Keys::generate(); - - let created = create(&client, &signer, &metadata("coop")) - .await - .expect("creates"); - - let community_head = created - .heads - .iter() - .find(|head| head.entity == *created.id.as_bytes()) - .expect("a community head"); - let writer = cord02::ControlWriter { - author: editor.public_key(), - read: control_group_key(&created.community_root, &created.id, cord02::ROOT_EPOCH) - .expect("a reading key"), - signer: control_signer_group_key( - &created.control_root.expect("a control root"), - &created.id, - cord02::ROOT_EPOCH, - ) - .expect("a signing key"), - }; - - // The editor holds no grant, so the edit is inert: it supersedes - // nothing. It is still a publication by that npub. - let (wrap, _) = writer - .set_community_metadata( - &editor, - &created.id, - &metadata("coop two"), - Some(community_head), - None, - Timestamp::now().as_secs() + 1, - ) - .await - .expect("publishes"); - client.database().save_event(&wrap).await.expect("saves"); - - let snapshot = fold(&client, &created) - .await - .expect("folds") - .expect("a control plane"); - - assert!( - snapshot.members.contains(&editor.public_key()), - "an edition's author is a member the fold has seen publishing" - ); - assert_eq!( - snapshot - .control - .community - .as_ref() - .map(|metadata| metadata.name.as_str()), - Some("coop"), - "an unauthorized edit still changes nothing" - ); - }); - } - - /// The other half of `create`: the membership must reach the account's - /// Community List, and a later create must union into it rather than replace - /// it (CORD-02 §8 read-modify-write). - #[test] - fn creating_a_community_records_the_membership_in_the_list() { - smol::block_on(async { - let client = client(); - let keys = Keys::generate(); - let signer = UniversalSigner::new(keys.clone()); - let self_pk = keys.public_key(); - - let created = create(&client, &signer, &metadata("coop")) - .await - .expect("creates"); - - let list = load_list(&client, &signer, self_pk) - .await - .expect("reads") - .expect("a list"); - - assert_eq!(list.frags, 1); - assert!(list.is_complete([0]), "the whole list is one fragment"); - assert!(list.is_live(&created.id)); - - let entry = list - .entries - .iter() - .find(|entry| entry.community_id == created.id) - .expect("the membership"); - assert_eq!( - entry.seed, entry.current, - "a fresh membership has one anchor" - ); - assert_eq!(entry.current.name, "coop"); - assert_eq!(entry.current.owner, created.owner); - assert_eq!(entry.current.root_epoch, created.root_epoch); - assert_eq!( - entry.current.control_pk, - created.control_pks.get(&0).copied() - ); - assert!( - entry.current.control_root.is_some(), - "the owner holds the control root" - ); - assert_eq!(entry.current.channels.len(), created.channels.len()); - assert_eq!(entry.added_at, created.added_at_ms); - - // A second create unions into the same document: the first - // membership survives and both are live. - let second = create(&client, &signer, &metadata("second")) - .await - .expect("creates"); - - let grown = load_list(&client, &signer, self_pk) - .await - .expect("reads") - .expect("a list"); - - assert!(grown.is_live(&created.id)); - assert!(grown.is_live(&second.id)); - assert_eq!(grown.entries.len(), 2); - }); - } - - /// The discovery fix: a membership the List carries is materialized even when - /// no state document has ever been written for it. - #[test] - fn load_materializes_a_membership_the_list_carries_with_no_state_document() { - smol::block_on(async { - let client = client(); - let keys = Keys::generate(); - let signer = UniversalSigner::new(keys.clone()); - - let listed = held( - CommunityId::from_bytes([0x42; 32]), - Keys::generate().public_key(), - ); - let list = CommunityList::default().joined(list_entry(&listed, "coop")); - store_fragment(&client, &signer, &list).await; - - assert!( - cache::load_state(&client, &listed.id) - .await - .expect("reads") - .is_none() - ); - - let loaded = load(&client, &signer, keys.public_key()) - .await - .expect("loads"); - let materialized = loaded - .iter() - .find(|state| state.id == listed.id) - .expect("the list materializes the community"); - - assert_eq!(materialized.owner, listed.owner); - assert_eq!(materialized.community_root, listed.community_root); - assert_eq!(materialized.control_root, listed.control_root); - assert_eq!(materialized.control_pks, listed.control_pks); - assert_eq!(materialized.channels, listed.channels); - assert_eq!(materialized.added_at_ms, listed.added_at_ms); - - // Discovery writes the document, so the next load is warm. - assert_eq!( - cache::load_state(&client, &listed.id) - .await - .expect("reads") - .map(|state| state.id), - Some(listed.id) - ); - }); - } - - /// Absence from the List is never a fact (§8): a held membership the List - /// does not mention survives alongside the one it does. - #[test] - fn load_keeps_a_local_membership_the_list_does_not_mention() { - smol::block_on(async { - let client = client(); - let keys = Keys::generate(); - let signer = UniversalSigner::new(keys.clone()); - - let listed = held( - CommunityId::from_bytes([0x42; 32]), - Keys::generate().public_key(), - ); - let local = held( - CommunityId::from_bytes([0x43; 32]), - Keys::generate().public_key(), - ); - - let list = CommunityList::default().joined(list_entry(&listed, "listed")); - store_fragment(&client, &signer, &list).await; - cache::save_state(&client, &local).await.expect("saves"); - - let loaded = load(&client, &signer, keys.public_key()) - .await - .expect("loads"); - let ids: BTreeSet = loaded.iter().map(|state| state.id).collect(); - - assert!(ids.contains(&listed.id)); - assert!(ids.contains(&local.id)); - }); - } - - /// Only a tombstone subtracts a membership (§8). - #[test] - fn a_tombstone_drops_a_held_membership() { - smol::block_on(async { - let client = client(); - let keys = Keys::generate(); - let signer = UniversalSigner::new(keys.clone()); - - let local = held( - CommunityId::from_bytes([0x42; 32]), - Keys::generate().public_key(), - ); - cache::save_state(&client, &local).await.expect("saves"); - - let list = CommunityList::default().tombstoned(local.id, u64::MAX); - store_fragment(&client, &signer, &list).await; - - let loaded = load(&client, &signer, keys.public_key()) - .await - .expect("loads"); - - assert!(loaded.iter().all(|state| state.id != local.id)); - }); - } - - /// The `concord/list` id must not be read as a community's subscription, or - /// every list event would refresh a community instead of triggering `load`. - #[test] - fn the_list_subscription_is_not_read_as_a_community_subscription() { - let id = CommunityId::from_bytes([0x42; 32]); - - assert!(is_list_subscription(&list_subscription_id())); - assert!(community_of(&list_subscription_id()).is_none()); - assert_eq!(community_of(&subscription_id(&id)), Some(id)); - } - - fn event_at(at_ms: u64) -> Event { - EventBuilder::new(Kind::TextNote, "wrap") - .custom_created_at(Timestamp::from_secs(at_ms / 1000)) - .finalize(&Keys::generate()) - .expect("signs") - } - - /// A key a rotation stepped off still reads its own history, but nothing - /// sealed after the rotation published it. - #[test] - fn a_superseded_key_reads_only_up_to_the_rotation_that_retired_it() { - let channel = ChannelId::from_bytes([0x9c; 32]); - let mut state = held( - CommunityId::from_bytes([0x42; 32]), - Keys::generate().public_key(), - ); - - state.channels = vec![concord::state::ChannelKeyRef { - id: channel, - name: "staff".to_owned(), - private: true, - epoch: Epoch(1), - key: Some([0x08; 32]), - priors: vec![HeldKey { - epoch: Epoch(0), - key: [0x07; 32], - retired_at: Some(Timestamp::from_secs(1_000)), - }], - }]; - - let planes = planes(&state).expect("planes"); - let retired = planes - .iter() - .find(|plane| plane.kind == PlaneKind::Channel(channel, Epoch(0))) - .expect("the retired epoch keeps a plane"); - - assert_eq!(retired.retired_at, Some(Timestamp::from_secs(1_000))); - assert!(retired.accepts(&event_at(1_000_000))); - assert!(!retired.accepts(&event_at(1_001_000))); - } - - /// A public channel reads one plane per held root epoch, and the plane for - /// the CURRENT root is the one the community writes to now. - /// - /// A Refounding moves the community root to a new epoch and every public - /// channel's plane with it (CORD-03 §1: a public channel's secret is the - /// community root, at the root epoch). Material the channel carries from - /// before the rotation names the old epoch, so a reader that trusts it asks - /// a retired address forever: the channel shows everything written before - /// the rotation and nothing after, while the control and guestbook planes — - /// which derive from the roots directly — stay current. - #[test] - fn a_refounding_moves_a_public_channels_plane_to_the_new_root_epoch() { - smol::block_on(async { - let client = client(); - let keys = Keys::generate(); - let signer = UniversalSigner::new(keys.clone()); - - let mut state = create(&client, &signer, &metadata("coop")) - .await - .expect("creates"); - let channel = state.channels[0].id; - let rotated_at = Timestamp::now(); - let prior_root = state.community_root; - - // A Refounding: the root moves to epoch 1, the prior one is held for - // history, and the channel's own material still names epoch 0. - state.community_root = [0x5a; 32]; - state.root_epoch = Epoch(1); - state.held_roots = vec![HeldRoot { - epoch: Epoch(0), - key: prior_root, - control_pk: None, - retired_at: Some(rotated_at), - }]; - - assert_eq!(state.channels[0].epoch, Epoch(0)); - - let current = channel_group_key(&state.community_root, &channel, state.root_epoch) - .expect("derives"); - let plane = planes(&state) - .expect("planes") - .into_iter() - .find(|plane| plane.address == current.pk()) - .expect("the current root's channel plane is subscribed and read"); - - assert_eq!(plane.kind, PlaneKind::Channel(channel, Epoch(1))); - assert!( - plane.accepts( - &EventBuilder::new(Kind::GiftWrap, "") - .finalize(plane.group.keys()) - .expect("signs") - ) - ); - - // And a message sealed at the current plane folds into the channel, - // which is what the timeline reads. - let rumor = concord::cord03::build_message( - keys.public_key(), - &channel, - state.root_epoch, - "after the refounding", - None, - rotated_at.as_secs().saturating_mul(1000), - None, - ); - let (wrap, _) = concord::cord03::seal_rumor(&rumor, &plane.group, &signer, false) - .await - .expect("seals"); - client.database().save_event(&wrap).await.expect("saves"); - - let snapshot = fold(&client, &state) - .await - .expect("folds") - .expect("a control plane"); - assert_eq!(snapshot.unreadable.get(&channel).copied(), None); - - let cached = cache::query_rumors( - &client, - &channel, - None, - 10, - Some(&concord::cord03::ROW_KINDS), - ) - .await - .expect("reads"); - - assert_eq!(cached.len(), 1, "a message at the new epoch reads back"); - }); - } - - /// A wrap a subscription delivered lands in the database and nowhere else. - /// The fold is what turns it into a cached rumor, which is the only thing - /// the timeline reads, so a live message depends on this step. - #[test] - fn a_fold_caches_a_channel_wrap_a_relay_delivered() { - smol::block_on(async { - let client = client(); - let keys = Keys::generate(); - let signer = UniversalSigner::new(keys.clone()); - - let created = create(&client, &signer, &metadata("coop")) - .await - .expect("creates"); - - let (channel, epoch, plane) = planes(&created) - .expect("planes") - .into_iter() - .find_map(|plane| match plane.kind { - PlaneKind::Channel(channel, epoch) => Some((channel, epoch, plane)), - _ => None, - }) - .expect("a channel plane"); - - let rumor = concord::cord03::build_message( - keys.public_key(), - &channel, - epoch, - "a live message", - None, - 1_700_000_000_000, - None, - ); - let (wrap, _) = concord::cord03::seal_rumor(&rumor, &plane.group, &signer, false) - .await - .expect("seals"); - - // What the SDK does with a wrap a standing subscription delivered. - client.database().save_event(&wrap).await.expect("saves"); - - let snapshot = fold(&client, &created) - .await - .expect("folds") - .expect("a control plane"); - - assert_eq!(snapshot.unreadable.get(&channel).copied(), None); - - let cached = cache::query_rumors( - &client, - &channel, - None, - 10, - Some(&concord::cord03::ROW_KINDS), - ) - .await - .expect("reads"); - - assert_eq!(cached.len(), 1, "the delivered wrap is cached as a rumor"); - assert_eq!(cached[0].pubkey, keys.public_key()); - }); - } - - /// A wrap addressed to a held channel plane that will not open is counted, so - /// the panel can tell a quiet room from one whose history it cannot read. - #[test] - fn a_fold_counts_the_channel_wraps_it_cannot_open() { - smol::block_on(async { - let client = client(); - let keys = Keys::generate(); - let signer = UniversalSigner::new(keys.clone()); - - let created = create(&client, &signer, &metadata("coop")) - .await - .expect("creates"); - - let (channel, plane) = planes(&created) - .expect("planes") - .into_iter() - .find_map(|plane| match plane.kind { - PlaneKind::Channel(channel, _) => Some((channel, plane)), - _ => None, - }) - .expect("a channel plane"); - - // Sealed to the channel's own address, but not a seal at all, so no - // held key opens it. - let junk = EventBuilder::new(Kind::GiftWrap, "not a seal") - .custom_created_at(Timestamp::from_secs(1_700_000_000)) - .finalize(plane.group.keys()) - .expect("signs"); - client.database().save_event(&junk).await.expect("saves"); - - let snapshot = fold(&client, &created) - .await - .expect("folds") - .expect("a control plane"); - - assert_eq!(snapshot.unreadable.get(&channel).copied(), Some(1)); - }); - } -} diff --git a/crates/community_ui/src/lib.rs b/crates/community_ui/src/lib.rs deleted file mode 100644 index a2d63602..00000000 --- a/crates/community_ui/src/lib.rs +++ /dev/null @@ -1,832 +0,0 @@ -use std::collections::{BTreeMap, HashMap}; -use std::fmt; - -use anyhow::Result; -use community::{ - ChannelId, ChatMessage, Community, CommunityEvent, Epoch, Intent, LOAD_OLDER_PAGES, - TIMELINE_PAGE, Timeline, -}; -use gpui::prelude::FluentBuilder; -use gpui::{ - AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, FollowMode, - IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString, - Styled, Subscription, Task, WeakEntity, Window, div, list, px, -}; -use nostr_sdk::prelude::EventId; -use smallvec::{SmallVec, smallvec}; -use theme::ActiveTheme; -use ui::avatar::Avatar; -use ui::button::{Button, ButtonVariants}; -use ui::dock::{Panel, PanelEvent}; -use ui::input::{InputEvent, Textarea, TextareaState}; -use ui::markdown::RenderedText; -use ui::message::WelcomeMessage; -use ui::notification::Notification; -use ui::scroll::Scrollbar; -use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex}; - -mod message; - -/// How near the top row a scroll has to come before the panel splices older history in. -const LOAD_OLDER_THRESHOLD: usize = 20; -/// A repeat message within this window keeps its run, so it carries no avatar or name. -const RUN_WINDOW_MS: u64 = 300_000; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Notice { - Stranded, - Removed(Epoch), - ChannelRemoved(Epoch), - MissingKey(Epoch), - Unreachable, - Unreadable(usize), -} - -impl fmt::Display for Notice { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Notice::Stranded => formatter.write_str( - "This invite is stale, the community has rotated past the epoch it names", - ), - Notice::Removed(epoch) => write!( - formatter, - "You were removed from this community at epoch {}. Its history stays readable", - epoch.0 - ), - Notice::ChannelRemoved(epoch) => write!( - formatter, - "A rotation removed you from this channel at epoch {}", - epoch.0 - ), - Notice::MissingKey(epoch) => write!( - formatter, - "Messages here can't be read yet, this channel's key for epoch {} is missing", - epoch.0 - ), - Notice::Unreachable => formatter.write_str("Couldn't reach the community's relays"), - Notice::Unreadable(1) => { - formatter.write_str("1 message here can't be read yet, no key we hold opens it") - } - Notice::Unreadable(count) => write!( - formatter, - "{count} messages here can't be read yet, no key we hold opens them" - ), - } - } -} - -impl Notice { - fn writable(self) -> bool { - matches!(self, Notice::Unreachable | Notice::Unreadable(_)) - } -} - -pub fn init( - community: Entity, - window: &mut Window, - cx: &mut App, -) -> Entity { - cx.new(|cx| CommunityPanel::new(community, window, cx)) -} - -/// Community Panel -pub struct CommunityPanel { - id: SharedString, - focus_handle: FocusHandle, - /// Community - community: WeakEntity, - /// The selected channel - channel: Option, - /// The selected channel's timeline (oldest first) - rows: Vec, - /// Rendered markdown content, keyed by message id and dropped when a row changes - rendered_texts_by_id: BTreeMap, - /// Whether the store holds rows older than `rows` - has_more: bool, - /// A round or a page read is in flight - loading: bool, - /// Message list state - list_state: ListState, - /// Message input state - input: Entity, - /// Spawned reads and publishes, cancelled when the panel closes - tasks: SmallVec<[Task>; 4]>, - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 2]>, -} - -impl CommunityPanel { - pub fn new(community: Entity, window: &mut Window, cx: &mut Context) -> Self { - let (id, name, channel) = { - let community = community.read(cx); - - ( - SharedString::from(format!("community-{}", community.id().to_hex())), - community.name(), - community.active_channel(), - ) - }; - - let input = cx.new(|cx| { - TextareaState::new(window, cx) - .placeholder(format!("Message {name}")) - .auto_grow(1, 20) - .clean_on_escape() - }); - - let mut subscriptions = smallvec![]; - - subscriptions.push( - cx.subscribe_in(&input, window, |this, _input, event, window, cx| { - if let InputEvent::PressEnter { .. } = event { - this.send(window, cx); - } - }), - ); - - subscriptions.push(cx.subscribe_in( - &community, - window, - |_this, _community, event, window, cx| { - match event { - CommunityEvent::Channel(..) => { - cx.defer_in(window, |this, window, cx| { - this.load(window, cx); - }); - } - CommunityEvent::Error(error) => { - window.push_notification(Notification::error(error.clone()), cx); - } - _ => { - cx.defer_in(window, |this, window, cx| { - this.reload(window, cx); - }); - } - }; - }, - )); - - cx.defer_in(window, |this, window, cx| { - this.list_state.set_follow_mode(FollowMode::Tail); - this.list_state.set_scroll_handler(cx.listener( - |this, event: &ListScrollEvent, window, cx| { - if event.visible_range.start <= LOAD_OLDER_THRESHOLD { - this.load_older(window, cx); - } - }, - )); - this.load(window, cx); - }); - - Self { - id, - focus_handle: cx.focus_handle(), - community: community.downgrade(), - channel, - rows: Vec::new(), - rendered_texts_by_id: BTreeMap::new(), - has_more: false, - loading: false, - list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)), - input, - tasks: smallvec![], - _subscriptions: subscriptions, - } - } - - /// The channel to show, following the community's selection. - fn resolve_channel(&mut self, cx: &App) -> Option { - let channel = self - .community - .read_with(cx, |community, _cx| community.active_channel()) - .ok() - .flatten(); - - if channel != self.channel { - self.channel = channel; - self.rows.clear(); - self.rendered_texts_by_id.clear(); - self.has_more = false; - self.loading = false; - self.list_state.reset(2); - } - - channel - } - - /// The list's item count: the welcome row, the load-older row, then every message row. - fn item_count(&self) -> usize { - self.rows.len() + 2 - } - - /// A timeline read, `before_ms` exclusive, or `None` for the newest rows. - fn read( - &self, - channel: ChannelId, - before_ms: Option, - cx: &App, - ) -> Option>> { - self.community - .read_with(cx, |community, cx| { - community.timeline(&channel, before_ms, TIMELINE_PAGE, cx) - }) - .ok() - } - - /// A channel round, or `None` once the community is gone. - fn sync( - &self, - channel: ChannelId, - intent: Intent, - cx: &mut App, - ) -> Option>> { - self.community - .update(cx, |community, cx| { - community.sync_channel(&channel, intent, cx) - }) - .ok() - } - - /// Paint the selected channel's cache, then catch it up from the relays. - fn load(&mut self, window: &mut Window, cx: &mut Context) { - let Some(channel) = self.resolve_channel(cx) else { - return; - }; - - self.reload(window, cx); - - // Opening a channel twice in a breath asks the relays once. - if self.due(channel, cx) { - self.round(channel, Intent::CatchUp, window, cx); - } - } - - /// Whether the community would actually round `channel`, or just serve it. - fn due(&self, channel: ChannelId, cx: &App) -> bool { - self.community - .read_with(cx, |community, _cx| community.due(&channel)) - .unwrap_or(true) - } - - /// Why the room is not showing messages, when it is not simply empty. - fn notice(&self, cx: &App) -> Option { - let channel = self.channel?; - - self.community - .read_with(cx, |community, _cx| { - if community.stranded() { - return Some(Notice::Stranded); - } - - if let Some(epoch) = community.removed_at() { - return Some(Notice::Removed(epoch)); - } - - if let Some(epoch) = community.channel_removed_at(&channel) { - return Some(Notice::ChannelRemoved(epoch)); - } - - if let Some(epoch) = community.missing_key(&channel) { - return Some(Notice::MissingKey(epoch)); - } - - if community - .progress(&channel) - .is_some_and(|progress| progress.failed && progress.errors > 0) - { - return Some(Notice::Unreachable); - } - - let unreadable = community.unreadable(&channel); - - (unreadable > 0).then_some(Notice::Unreadable(unreadable)) - }) - .ok() - .flatten() - } - - /// Run a round for `channel`, its completion re-reads the timeline. - fn round( - &mut self, - channel: ChannelId, - intent: Intent, - window: &mut Window, - cx: &mut Context, - ) { - let Some(round) = self.sync(channel, intent, cx) else { - return; - }; - - self.loading = true; - cx.notify(); - - let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { - let result = round.await; - - this.update_in(cx, |this, window, cx| { - this.loading = false; - - if let Err(error) = result { - window.push_notification( - Notification::error(error.to_string()).autohide(false), - cx, - ); - } - })?; - - Ok(()) - }); - - self.tasks.push(task); - } - - /// Run the newest round again after a relay failure. - fn retry(&mut self, window: &mut Window, cx: &mut Context) { - let Some(channel) = self.channel else { - return; - }; - - self.round(channel, Intent::CatchUp, window, cx); - } - - /// Read the newest page and fold it into what is on screen. - fn reload(&mut self, window: &mut Window, cx: &mut Context) { - let Some(channel) = self.resolve_channel(cx) else { - return; - }; - - let Some(timeline) = self.read(channel, None, cx) else { - return; - }; - - let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { - match timeline.await { - Ok(timeline) => this.update(cx, |this, cx| this.apply(channel, timeline, cx))?, - Err(error) => { - this.update_in(cx, |_this, window, cx| { - window.push_notification( - Notification::error(error.to_string()).autohide(false), - cx, - ); - })?; - } - } - - Ok(()) - }); - - self.tasks.push(task); - } - - /// Splice the page of history above the oldest row on screen. - fn load_older(&mut self, window: &mut Window, cx: &mut Context) { - if self.loading || !self.has_more { - return; - } - - let Some(channel) = self.channel else { - return; - }; - - let Some(before_ms) = self - .rows - .first() - .map(|message| message.at_ms.saturating_sub(1)) - else { - return; - }; - - let Some(page) = self.read(channel, Some(before_ms), cx) else { - return; - }; - - self.loading = true; - - let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { - let timeline = match page.await { - Ok(timeline) => timeline, - Err(error) => { - this.update_in(cx, |this, window, cx| { - this.loading = false; - window.push_notification( - Notification::error(error.to_string()).autohide(false), - cx, - ); - })?; - return Ok(()); - } - }; - - let swept = this.update(cx, |this, cx| { - this.prepend(channel, timeline, cx); - !this.has_more - })?; - - if !swept { - this.update(cx, |this, _cx| this.loading = false)?; - return Ok(()); - } - - let round = this.update(cx, |this, cx| { - this.sync( - channel, - Intent::Older { - pages: LOAD_OLDER_PAGES, - }, - cx, - ) - })?; - - let Some(round) = round else { - this.update(cx, |this, _cx| this.loading = false)?; - return Ok(()); - }; - - match round.await { - Ok(_) => { - let page = - this.update(cx, |this, cx| this.read(channel, Some(before_ms), cx))?; - - if let Some(page) = page { - match page.await { - Ok(timeline) => { - this.update(cx, |this, cx| this.prepend(channel, timeline, cx))? - } - Err(error) => this.update_in(cx, |_this, window, cx| { - window.push_notification( - Notification::error(error.to_string()).autohide(false), - cx, - ); - })?, - } - } - - this.update(cx, |this, _cx| this.loading = false)?; - } - Err(error) => { - this.update_in(cx, |this, window, cx| { - this.loading = false; - window.push_notification( - Notification::error(error.to_string()).autohide(false), - cx, - ); - })?; - } - } - - Ok(()) - }); - - self.tasks.push(task); - } - - /// Fold a freshly read window into the rows on screen. - fn apply(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context) { - if self.channel != Some(channel) { - return; - } - - let Timeline { messages, has_more } = timeline; - - let connected = self - .rows - .last() - .is_some_and(|last| messages.iter().any(|message| message.id == last.id)); - - if !connected { - self.rows = messages; - self.rendered_texts_by_id.clear(); - self.has_more = has_more; - self.list_state.reset(self.item_count()); - cx.notify(); - return; - } - - self.merge(messages); - cx.notify(); - } - - /// Splice older rows in above what is on screen. - fn prepend(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context) { - if self.channel != Some(channel) { - return; - } - - self.has_more = timeline.has_more; - self.merge(timeline.messages); - cx.notify(); - } - - /// Fold read rows into what is on screen, keeping the list in time order. - fn merge(&mut self, messages: Vec) { - let mut shown: HashMap = self - .rows - .iter() - .enumerate() - .map(|(ix, message)| (message.id, ix)) - .collect(); - - let mut fresh = Vec::new(); - - for message in messages { - match shown.get(&message.id).copied() { - Some(ix) => { - // Drop the cached render when the text changes, so edits re-parse. - if self.rows[ix].content != message.content { - self.rendered_texts_by_id.remove(&message.id); - } - self.rows[ix] = message; - } - None => fresh.push(message), - } - } - - for message in fresh { - if shown.contains_key(&message.id) { - continue; - } - - let at = self - .rows - .partition_point(|row| (row.at_ms, row.id) <= (message.at_ms, message.id)); - - shown.insert(message.id, at); - self.rows.insert(at, message); - // The welcome and load-older rows sit above the messages. - self.list_state.splice(at + 2..at + 2, 1); - } - } - - fn send(&mut self, window: &mut Window, cx: &mut Context) { - let content = self.input.read(cx).value().trim().to_owned(); - - if content.is_empty() { - window.push_notification("Cannot send an empty message", cx); - return; - } - - let Some(channel) = self.resolve_channel(cx) else { - return; - }; - - if let Some(notice) = self.notice(cx).filter(|notice| !notice.writable()) { - window.push_notification(Notification::error(notice.to_string()).autohide(false), cx); - return; - } - - let Ok(send) = self.community.read_with(cx, |community, cx| { - community.send(&channel, &content, None, cx) - }) else { - return; - }; - - let Some(send) = send else { - window.push_notification(Notification::error("Failed to send the message"), cx); - return; - }; - - self.input.update(cx, |input, cx| { - input.set_value("", window, cx); - }); - - let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { - match send.await { - Ok(_) => { - this.update_in(cx, |this, window, cx| this.reload(window, cx))?; - } - Err(error) => { - this.update_in(cx, |_this, window, cx| { - window.push_notification( - Notification::error(error.to_string()).autohide(false), - cx, - ); - })?; - } - } - - Ok(()) - }); - - self.tasks.push(task); - } - - /// The honest reason this room has nothing to show, with a way out of it. - fn render_notice(&self, notice: Notice, cx: &mut Context) -> AnyElement { - h_flex() - .w_full() - .justify_center() - .items_center() - .gap_2() - .px_3() - .py_2() - .text_sm() - .text_color(cx.theme().text_placeholder) - .child(notice.to_string()) - .when(notice == Notice::Unreachable, |this| { - this.child( - Button::new("retry-round") - .label("Retry") - .ghost() - .small() - .loading(self.loading) - .on_click(cx.listener(|this, _event, window, cx| this.retry(window, cx))), - ) - }) - .into_any_element() - } - - /// The row at index 0: the welcome message for the channel. - fn render_welcome(&self, cx: &Context) -> AnyElement { - let (name, avatar) = self - .community - .read_with(cx, |community, _cx| { - let seed = community.id().to_hex(); - let avatar = Avatar::from_source(community.icon()).seed(seed).large(); - - (community.name(), avatar) - }) - .unwrap_or_else(|_| (SharedString::from("this community"), Avatar::new(None))); - - WelcomeMessage::new("welcome") - .icon(avatar) - .title(format!("Welcome to {}", name)) - .message(format!("This is the start of the {} channel.", name)) - .into_any_element() - } - - /// The row at index 1: the affordance that pages older history in. - fn render_older(&self, cx: &mut Context) -> AnyElement { - if !self.has_more { - return div().into_any_element(); - } - - h_flex() - .w_full() - .justify_center() - .py_2() - .child( - Button::new("load-older") - .label(if self.loading { - "Loading earlier messages…" - } else { - "Load earlier messages" - }) - .ghost() - .small() - .loading(self.loading) - .on_click(cx.listener(|this, _event, window, cx| this.load_older(window, cx))), - ) - .into_any_element() - } - - /// Whether the row at `index` opens a run from one author. - fn opens_run(&self, index: usize) -> bool { - let Some(current) = self.rows.get(index) else { - return true; - }; - - let Some(previous) = index.checked_sub(1).and_then(|index| self.rows.get(index)) else { - return true; - }; - - current.author != previous.author - || current.at_ms.saturating_sub(previous.at_ms) > RUN_WINDOW_MS - } - - fn render_message( - &mut self, - ix: usize, - window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - if ix == 0 { - return self.render_welcome(cx); - } - - if ix == 1 { - return self.render_older(cx); - } - - // The welcome and load-older rows sit above the messages. - let Some(message) = self.rows.get(ix - 2) else { - return div().into_any_element(); - }; - - let show_author = self.opens_run(ix - 2); - - let content = if message.deleted { - message::deleted(cx) - } else { - self.rendered_texts_by_id - .entry(message.id) - .or_insert_with(|| RenderedText::new(&message.content, &[], true)) - .element(ix.into(), window, cx) - }; - - message::render(ix, message, content, show_author, cx) - } - - fn render_composer(&self, cx: &mut Context) -> impl IntoElement { - let writable = self.notice(cx).is_none_or(|notice| notice.writable()); - - h_flex() - .flex_shrink_0() - .w_full() - .p_2() - .gap_1() - .items_end() - .child(Textarea::new(&self.input).appearance(false).flex_1()) - .child( - Button::new("send") - .icon(IconName::PaperPlaneFill) - .tooltip("Send") - .ghost() - .large() - .disabled(!writable) - .on_click(cx.listener(|this, _event, window, cx| { - this.send(window, cx); - })), - ) - } -} - -impl Panel for CommunityPanel { - fn panel_id(&self) -> SharedString { - self.id.clone() - } - - fn title(&self, cx: &App) -> AnyElement { - self.community - .read_with(cx, |community, _cx| { - let seed = community.id().to_hex(); - let avatar = Avatar::from_source(community.icon()).seed(seed).xsmall(); - - h_flex() - .gap_1() - .text_xs() - .child(avatar) - .child(community.name()) - .into_any_element() - }) - .unwrap_or_else(|_| div().text_xs().child("Unknown").into_any_element()) - } - - fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec