Files
coop/crates/concord/src/state.rs
T
2026-09-23 01:12:51 +00:00

628 lines
22 KiB
Rust

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<Timestamp>,
}
/// 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<PublicKey>,
/// The publish time of the rotation that superseded this root.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired_at: Option<Timestamp>,
}
#[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<HeldKey>,
}
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<Timestamp>,
/// The oldest wrap paged back to, so the next round resumes below it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oldest: Option<Timestamp>,
/// 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<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
match (held, round) {
(Some(held), Some(round)) => Some(held.max(round)),
(held, None) => held,
(None, round) => round,
}
}
fn earlier(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
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/<community_id>`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityState {
pub id: CommunityId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
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<u64, PublicKey>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelKeyRef>,
pub relays: Vec<RelayUrl>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub heads: Vec<EntityHead>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub banned: BTreeSet<PublicKey>,
/// Where each channel's history sync has reached.
#[serde(
default,
rename = "channel_cursors",
skip_serializing_if = "BTreeMap::is_empty"
)]
pub cursors: BTreeMap<ChannelId, ChannelCursor>,
/// Root epochs the community has rotated past that this client still holds.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub held_roots: Vec<HeldRoot>,
/// The epoch a channel rotation removed us at.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub channel_cuts: BTreeMap<ChannelId, Epoch>,
/// The npubs whose rotation minted an epoch of this community we verified.
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub refounders: BTreeSet<PublicKey>,
/// The base epoch we were excluded at.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed_at: Option<Epoch>,
/// 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<Self> {
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<Self> {
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<HeldRoot> {
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<HeldKey> {
let Some(held) = self.channels.iter().find(|held| held.id == *channel) else {
return Vec::new();
};
if held.private {
let mut keys: Vec<HeldKey> = 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<String> {
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)]));
}
}