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(), 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); }); } }