use std::collections::{BTreeMap, BTreeSet}; use anyhow::Result; use data_encoding::HEXLOWER; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use crate::cord02::list::{CommunityListEntry, JoinMaterial}; use crate::cord02::{ ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH, }; use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk}; use crate::cord05::ChannelGrant; use crate::derive::control_signer_group_key; use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32}; /// The `concord/` namespace for locally-keyed documents. pub const STATE_PREFIX: &str = "concord/"; /// A key epoch the client still holds, retained so history stays readable. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct HeldKey { pub epoch: Epoch, pub key: [u8; 32], /// The publish time of the rotation that superseded this key. #[serde(default, skip_serializing_if = "Option::is_none")] pub retired_at: Option, } /// A community root epoch the client still holds, retained for the same reason. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct HeldRoot { pub epoch: Epoch, pub key: [u8; 32], /// The epoch's Control Plane signer, when the rotation delivered one. #[serde(default, skip_serializing_if = "Option::is_none")] pub control_pk: Option, /// The publish time of the rotation that superseded this root. #[serde(default, skip_serializing_if = "Option::is_none")] pub retired_at: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ChannelKeyRef { pub id: ChannelId, pub name: String, pub private: bool, pub epoch: Epoch, /// The channel's read secret when the member was granted it. #[serde(default, skip_serializing_if = "Option::is_none")] pub key: Option<[u8; 32]>, /// Keys this one superseded, retained so a rotation never blanks history. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub priors: Vec, } impl ChannelKeyRef { /// The write coordinate: only the current epoch is ever published under. pub fn current(&self) -> Option<(Epoch, [u8; 32])> { self.key.map(|key| (self.epoch, key)) } } /// How far a channel's history sync has reached, in wrap times. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ChannelCursor { /// The newest wrap ingested, so a live subscription knows where to resume. #[serde(default, skip_serializing_if = "Option::is_none")] pub newest: Option, /// The oldest wrap paged back to, so the next round resumes below it. #[serde(default, skip_serializing_if = "Option::is_none")] pub oldest: Option, /// History verifiably swept to the bottom. #[serde(default)] pub exhausted: bool, } impl ChannelCursor { pub fn merge(self, round: Self) -> Self { Self { newest: later(self.newest, round.newest), oldest: earlier(self.oldest, round.oldest), exhausted: self.exhausted || round.exhausted, } } } fn later(held: Option, round: Option) -> Option { match (held, round) { (Some(held), Some(round)) => Some(held.max(round)), (held, None) => held, (None, round) => round, } } fn earlier(held: Option, round: Option) -> Option { match (held, round) { (Some(held), Some(round)) => Some(held.min(round)), (held, None) => held, (None, round) => round, } } /// One local document per community, keyed by `concord/`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CommunityState { pub id: CommunityId, #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, pub owner: PublicKey, pub owner_salt: [u8; 32], pub community_root: [u8; 32], pub root_epoch: Epoch, #[serde(default, skip_serializing_if = "Option::is_none")] pub control_root: Option<[u8; 32]>, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub control_pks: BTreeMap, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub channels: Vec, pub relays: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub heads: Vec, #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub banned: BTreeSet, /// Where each channel's history sync has reached. #[serde( default, rename = "channel_cursors", skip_serializing_if = "BTreeMap::is_empty" )] pub cursors: BTreeMap, /// Root epochs the community has rotated past that this client still holds. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub held_roots: Vec, /// The epoch a channel rotation removed us at. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub channel_cuts: BTreeMap, /// The npubs whose rotation minted an epoch of this community we verified. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub refounders: BTreeSet, /// The base epoch we were excluded at. #[serde(default, skip_serializing_if = "Option::is_none")] pub removed_at: Option, /// A complete rotation ahead of our epoch predates our join and carries no blob. #[serde(default)] pub stranded: bool, #[serde(default)] pub dissolved: bool, /// When this community joined the member's list, in milliseconds. pub added_at_ms: u64, } impl CommunityState { pub fn from_genesis( genesis: &CommunityGenesis, editions: &[ParsedEdition], added_at_ms: u64, ) -> Result { let mut channels = Vec::new(); let mut heads = Vec::with_capacity(editions.len()); let mut relays = Vec::new(); let mut name = None; for edition in editions { heads.push(EntityHead { entity: edition.entity, version: edition.version, self_hash: edition.self_hash, rumor_id: edition.rumor_id, }); match edition.subkind.as_str() { vsk::COMMUNITY_METADATA => { let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?; relays.extend( metadata .relays .iter() .filter_map(|relay| RelayUrl::parse(relay).ok()), ); name = label(&metadata.name); } vsk::CHANNEL_METADATA => { let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?; channels.push(ChannelKeyRef { id: ChannelId::from_bytes(edition.entity), name: metadata.name, private: metadata.private, epoch: ROOT_EPOCH, key: None, priors: Vec::new(), }); } _ => {} } } let control_pks = BTreeMap::from([( ROOT_EPOCH.0, control_signer_group_key( &genesis.control_root, &genesis.identity.community_id, ROOT_EPOCH, )? .pk(), )]); Ok(Self { id: genesis.identity.community_id, name, owner: genesis.identity.owner, owner_salt: genesis.identity.owner_salt, community_root: genesis.community_root, root_epoch: ROOT_EPOCH, control_root: Some(genesis.control_root), control_pks, channels, relays, heads, 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, }) } pub fn from_join_material(material: &JoinMaterial, added_at_ms: u64) -> Result { let control_pks = match material.control_pk { Some(address) => BTreeMap::from([(material.root_epoch.0, address)]), None => BTreeMap::new(), }; let mut channels = Vec::with_capacity(material.channels.len()); for grant in &material.channels { let key = match &grant.key { Some(key) => Some(decode_hex_32(key)?), None => None, }; channels.push(ChannelKeyRef { id: grant.id, name: grant.name.clone(), private: key.is_some(), epoch: grant.epoch, key, priors: Vec::new(), }); } Ok(Self { id: material.community_id, name: label(&material.name), owner: material.owner, owner_salt: decode_hex_32(&material.owner_salt)?, community_root: decode_hex_32(&material.community_root)?, root_epoch: material.root_epoch, control_root: match &material.control_root { Some(root) => Some(decode_hex_32(root)?), None => None, }, control_pks, channels, relays: material .relays .iter() .filter_map(|relay| RelayUrl::parse(relay).ok()) .collect(), 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, }) } pub fn identifier(&self) -> String { state_identifier(&self.id) } /// Every root epoch we hold, the current one first. pub fn roots(&self) -> Vec { let mut roots = Vec::with_capacity(self.held_roots.len() + 1); roots.push(HeldRoot { epoch: self.root_epoch, key: self.community_root, control_pk: self.control_pks.get(&self.root_epoch.0).copied(), retired_at: None, }); roots.extend(self.held_roots.iter().copied()); roots } /// Every secret held for a channel, newest epoch first. pub fn held_keys(&self, channel: &ChannelId) -> Vec { let Some(held) = self.channels.iter().find(|held| held.id == *channel) else { return Vec::new(); }; if held.private { let mut keys: Vec = held .key .map(|key| HeldKey { epoch: held.epoch, key, retired_at: None, }) .into_iter() .collect(); keys.extend(held.priors.iter().copied()); return keys; } self.roots() .into_iter() .map(|root| HeldKey { epoch: root.epoch, key: root.key, retired_at: root.retired_at, }) .collect() } /// Whether a channel rotation removed us at or after `epoch`. pub fn channel_cut(&self, channel: &ChannelId, epoch: Epoch) -> bool { self.channel_cuts .get(channel) .is_some_and(|cut| epoch <= *cut) } pub fn floors(&self) -> Floors { self.heads .iter() .map(|head| (head.entity, head.clone())) .collect() } pub fn apply_fold(&mut self, fold: &ControlFold) { self.heads = fold.floors.values().cloned().collect(); self.banned = fold.banned.clone(); if let Some(community) = &fold.community { self.relays = community .relays .iter() .filter_map(|relay| RelayUrl::parse(relay).ok()) .collect(); if let Some(name) = label(&community.name) { self.name = Some(name); } } for (id, metadata) in &fold.channels { if metadata.deleted.unwrap_or(false) { self.channels.retain(|channel| channel.id != *id); continue; } match self.channels.iter_mut().find(|channel| channel.id == *id) { Some(channel) => { channel.name = metadata.name.clone(); if !metadata.private { channel.private = false; } } None if !metadata.private => self.channels.push(ChannelKeyRef { id: *id, name: metadata.name.clone(), private: false, epoch: self.root_epoch, key: None, priors: Vec::new(), }), None => {} } } } } pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry { let material = JoinMaterial { community_id: state.id, owner: state.owner, owner_salt: HEXLOWER.encode(&state.owner_salt), community_root: HEXLOWER.encode(&state.community_root), root_epoch: state.root_epoch, control_pk: state.control_pks.get(&state.root_epoch.0).copied(), control_root: state.control_root.map(|root| HEXLOWER.encode(&root)), channels: state .channels .iter() .map(|channel| ChannelGrant { id: channel.id, key: channel.key.map(|key| HEXLOWER.encode(&key)), epoch: channel.epoch, name: channel.name.clone(), extra: Extra::default(), }) .collect(), relays: state.relays.iter().map(RelayUrl::to_string).collect(), name: name.to_owned(), extra: Extra::default(), }; CommunityListEntry { community_id: state.id, seed: material.clone(), current: material, added_at: state.added_at_ms, extra: Extra::default(), } } fn label(name: &str) -> Option { let trimmed = name.trim(); (!trimmed.is_empty()).then(|| trimmed.to_owned()) } /// The local document key a community's state is stored under. pub fn state_identifier(id: &CommunityId) -> String { format!("{STATE_PREFIX}{}", id.to_hex()) } #[cfg(test)] mod tests { use super::*; #[test] fn a_cursor_merge_only_moves_forward_and_never_seals() { let held = ChannelCursor { newest: Some(Timestamp::from_secs(1_000)), oldest: Some(Timestamp::from_secs(5_000)), exhausted: false, }; // An incomplete round reports nothing and moves neither bound. assert_eq!(held.merge(ChannelCursor::default()), held); let merged = held.merge(ChannelCursor { newest: Some(Timestamp::from_secs(2_000)), oldest: Some(Timestamp::from_secs(3_000)), exhausted: true, }); assert_eq!( merged, ChannelCursor { newest: Some(Timestamp::from_secs(2_000)), oldest: Some(Timestamp::from_secs(3_000)), exhausted: true, } ); // A later round that learned less cannot walk either bound back. assert_eq!( merged.merge(ChannelCursor { newest: Some(Timestamp::from_secs(1_500)), oldest: Some(Timestamp::from_secs(4_000)), exhausted: false, }), merged ); } /// A cursor stored when the boundaries were milliseconds must not be read as /// seconds. The key it was stored under is gone, so the document's counters /// are ignored and the channel re-syncs rather than being sealed off by an /// `exhausted` that outlived the bounds it was earned against. #[test] fn a_cursor_stored_in_the_old_unit_is_dropped_rather_than_reinterpreted() { let channel = ChannelId::from_bytes([0x9c; 32]); let cursor = ChannelCursor { newest: Some(Timestamp::from_secs(1_700_000_000)), exhausted: true, ..ChannelCursor::default() }; let mut state = 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::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, }; // The document a version that stored milliseconds wrote: its own key, // and boundaries padded by a thousand. let mut stored = serde_json::Map::new(); stored.insert( channel.to_hex(), serde_json::json!({ "newest_ms": 1_700_000_000_000u64, "oldest_ms": 1_699_999_000_000u64, "exhausted": true }), ); let mut legacy = serde_json::to_value(&state).expect("serializes"); legacy .as_object_mut() .expect("a document") .insert("cursors".to_owned(), serde_json::Value::Object(stored)); let read: CommunityState = serde_json::from_value(legacy).expect("deserializes"); assert!( read.cursors.is_empty(), "a millisecond cursor is not a seconds cursor" ); // A typed cursor still round-trips under the key it is written with. state.cursors.insert(channel, cursor); let document = serde_json::to_value(&state).expect("serializes"); let read: CommunityState = serde_json::from_value(document).expect("deserializes"); assert_eq!(read.cursors.get(&channel), Some(&cursor)); } #[test] fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() { let owner = Keys::generate().public_key(); let control_pk = Keys::generate().public_key(); let staff = ChannelId::from_bytes([0x9c; 32]); let general = ChannelId::from_bytes([0x9d; 32]); let material = JoinMaterial { community_id: CommunityId::from_bytes([0x42; 32]), owner, owner_salt: "01".repeat(32), community_root: "02".repeat(32), root_epoch: Epoch(3), control_pk: Some(control_pk), control_root: Some("03".repeat(32)), channels: vec![ ChannelGrant { id: staff, key: Some("04".repeat(32)), epoch: Epoch(2), name: "staff".to_owned(), extra: Extra::default(), }, ChannelGrant { id: general, key: None, epoch: Epoch(0), name: "general".to_owned(), extra: Extra::default(), }, ], relays: vec!["wss://relay.example".to_owned()], name: "Room".to_owned(), extra: Extra::default(), }; let state = CommunityState::from_join_material(&material, 7).expect("materializes"); assert_eq!(state.id, material.community_id); assert_eq!(state.owner, owner); assert_eq!(state.owner_salt, [0x01; 32]); assert_eq!(state.community_root, [0x02; 32]); assert_eq!(state.root_epoch, Epoch(3)); assert_eq!(state.control_root, Some([0x03; 32])); assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)])); assert!( state.heads.is_empty(), "the first control fold fills the heads" ); assert!(state.banned.is_empty()); assert!(!state.dissolved); assert_eq!(state.relays.len(), 1); assert_eq!(state.added_at_ms, 7); // A granted key lands on the channel and makes it private; a grant with // no key is a public channel. let granted = state .channels .iter() .find(|c| c.id == staff) .expect("staff"); assert!(granted.private); assert_eq!(granted.key, Some([0x04; 32])); assert_eq!(granted.epoch, Epoch(2)); assert_eq!(granted.name, "staff"); let public = state .channels .iter() .find(|c| c.id == general) .expect("general"); assert!(!public.private); assert_eq!(public.key, None); // A member who is not staff carries no control_root, but reading needs no // secret: the address rides in the material either way. let mut member = material.clone(); member.control_root = None; let state = CommunityState::from_join_material(&member, 7).expect("materializes"); assert_eq!(state.control_root, None); assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)])); } }