add control fold and roster

This commit is contained in:
2026-09-16 20:05:51 +07:00
parent d926c1e3ea
commit 4329385abe
8 changed files with 2016 additions and 111 deletions
+572 -58
View File
@@ -1,14 +1,21 @@
use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Result, bail};
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::derive::{
community_id_of, control_group_key, control_signer_group_key, verify_community_id,
};
use crate::edition::{EditionFields, ParsedEdition, build_edition, parse_edition, vsk};
use crate::edition::{
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
build_edition, fold_head, parse_edition, vsk,
};
use crate::roles::{
AuthorityEdition, CommunityRoles, Permissions, Roster, citation_ok, fold_roster,
};
use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
use crate::{ChannelId, CommunityId, Epoch, GroupKey, random_32};
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32};
pub const MAX_NAME_BYTES: usize = 64;
pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
@@ -17,12 +24,8 @@ pub const MAX_RELAYS: usize = 5;
pub const GENERAL_CHANNEL: &str = "general";
pub const ROOT_EPOCH: Epoch = Epoch(0);
/// The first edition every entity starts at. Genuinely 1, not 0: a version of
/// 0 is what the fold treats as a gap.
const GENESIS_VERSION: u64 = 1;
type Extra = Map<String, Value>;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ImageRef {
pub url: String,
@@ -64,7 +67,6 @@ pub struct ChannelMetadata {
pub extra: Extra,
}
/// A community's permanent identity.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommunityIdentity {
pub community_id: CommunityId,
@@ -78,12 +80,6 @@ impl CommunityIdentity {
}
}
/// Everything a creation mints:
///
/// - Identity
/// - Roots an invite will carry
/// - First channel
/// - Two genesis wraps to publish
#[derive(Debug, Clone)]
pub struct CommunityGenesis {
pub identity: CommunityIdentity,
@@ -93,29 +89,14 @@ pub struct CommunityGenesis {
pub wraps: Vec<Event>,
}
/// Mints a community and signs its two genesis editions.
pub fn genesis(
owner: &Keys,
metadata: &CommunityMetadata,
at_secs: u64,
) -> Result<CommunityGenesis> {
let mut metadata = metadata.clone();
if metadata.name.len() > MAX_NAME_BYTES {
bail!("community name exceeds {MAX_NAME_BYTES} bytes");
}
if metadata
.description
.as_ref()
.is_some_and(|description| description.len() > MAX_DESCRIPTION_BYTES)
{
bail!("community description exceeds {MAX_DESCRIPTION_BYTES} bytes");
}
metadata.relays.truncate(MAX_RELAYS);
let metadata_content = encode_metadata(metadata)?;
let owner_salt = random_32()?;
let identity = CommunityIdentity {
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
owner: owner.public_key(),
@@ -129,7 +110,6 @@ pub fn genesis(
let read = control_group_key(&community_root, &identity.community_id, ROOT_EPOCH)?;
let signer = control_signer_group_key(&control_root, &identity.community_id, ROOT_EPOCH)?;
let metadata_content = serde_json::to_string(&metadata)?;
let channel_content = serde_json::to_string(&ChannelMetadata {
name: GENERAL_CHANNEL.to_owned(),
private: false,
@@ -190,6 +170,277 @@ pub fn open_edition(
Ok(parse_edition(&opened.rumor)?)
}
/// Appends editions to entity chains.
pub struct ControlWriter {
pub author: PublicKey,
pub read: GroupKey,
pub signer: GroupKey,
}
pub struct Edition<'a> {
pub subkind: &'a str,
pub entity: [u8; 32],
pub content: &'a str,
/// The head this edition supersedes.
///
/// `None` starts the chain.
pub head: Option<&'a EntityHead>,
pub citation: Option<AuthorityCitation>,
}
impl ControlWriter {
pub fn publish(
&self,
keys: &Keys,
edition: Edition<'_>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let rumor = build_edition(EditionFields {
author: self.author,
subkind: edition.subkind,
entity: edition.entity,
version: edition
.head
.map_or(GENESIS_VERSION, |head| head.version + 1),
prev: edition.head.map(|head| head.self_hash),
citation: edition.citation,
content: edition.content,
at_secs,
});
let parsed = parse_edition(&rumor)?;
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?;
Ok((wrap, EntityHead::from(&parsed)))
}
pub fn set_community_metadata(
&self,
keys: &Keys,
community_id: &CommunityId,
metadata: &CommunityMetadata,
head: Option<&EntityHead>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = encode_metadata(metadata)?;
self.publish(
keys,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head,
citation: None,
},
at_secs,
)
}
pub fn set_channel_metadata(
&self,
keys: &Keys,
channel: &ChannelId,
metadata: &ChannelMetadata,
head: Option<&EntityHead>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = serde_json::to_string(metadata)?;
self.publish(
keys,
Edition {
subkind: vsk::CHANNEL_METADATA,
entity: *channel.as_bytes(),
content: &content,
head,
citation: None,
},
at_secs,
)
}
}
fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> {
if metadata.name.len() > MAX_NAME_BYTES {
bail!("community name exceeds {MAX_NAME_BYTES} bytes");
}
if metadata
.description
.as_ref()
.is_some_and(|description| description.len() > MAX_DESCRIPTION_BYTES)
{
bail!("community description exceeds {MAX_DESCRIPTION_BYTES} bytes");
}
let mut metadata = metadata.clone();
metadata.relays.truncate(MAX_RELAYS);
Ok(serde_json::to_string(&metadata)?)
}
#[derive(Debug, Clone, Default)]
pub struct ControlFold {
pub roles: CommunityRoles,
pub banned: BTreeSet<PublicKey>,
pub community: Option<CommunityMetadata>,
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
pub floors: Floors,
pub gapped: bool,
}
pub fn fold_control(
owner: &PublicKey,
community_id: &CommunityId,
editions: &[ParsedEdition],
floors: &Floors,
held_bans: &BTreeSet<PublicKey>,
) -> ControlFold {
let authority: Vec<AuthorityEdition> = editions
.iter()
.filter_map(|edition| AuthorityEdition::parse(edition, community_id))
.collect();
let roster = fold_roster(owner, community_id, &authority, floors, held_bans);
let metadata = fold_metadata(owner, community_id, editions, &roster, floors);
let mut floors = roster.floors;
floors.extend(metadata.floors);
ControlFold {
roles: roster.roles,
banned: roster.banned,
community: metadata.community,
channels: metadata.channels,
floors,
gapped: roster.gapped || metadata.gapped,
}
}
#[derive(Debug, Default)]
struct MetadataFold {
community: Option<CommunityMetadata>,
channels: BTreeMap<ChannelId, ChannelMetadata>,
floors: Floors,
gapped: bool,
}
fn fold_metadata(
owner: &PublicKey,
community_id: &CommunityId,
editions: &[ParsedEdition],
roster: &Roster,
floors: &Floors,
) -> MetadataFold {
let judge = Judge {
owner,
community_id,
roster,
floors,
};
let community_entity = *community_id.as_bytes();
let mut community: Vec<&ParsedEdition> = Vec::new();
let mut channels: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
for edition in editions {
match edition.subkind.as_str() {
// A channel addressed at the community's own coordinate would share, and
// corrupt, the metadata chain's floor.
vsk::COMMUNITY_METADATA if edition.entity == community_entity => {
community.push(edition)
}
vsk::CHANNEL_METADATA if edition.entity != community_entity => {
channels.entry(edition.entity).or_default().push(edition);
}
_ => {}
}
}
let mut fold = MetadataFold::default();
if let Some(head) = authorized_head(
&judge,
community_entity,
&community,
Permissions::MANAGE_METADATA,
&mut fold.gapped,
) {
fold.community = serde_json::from_str(&head.content).ok();
fold.floors.insert(head.entity, EntityHead::from(head));
}
for (entity, candidates) in &channels {
let Some(head) = authorized_head(
&judge,
*entity,
candidates,
Permissions::MANAGE_CHANNELS,
&mut fold.gapped,
) else {
continue;
};
fold.floors.insert(*entity, EntityHead::from(head));
if let Ok(metadata) = serde_json::from_str::<ChannelMetadata>(&head.content) {
fold.channels
.insert(ChannelId::from_bytes(*entity), metadata);
}
}
fold
}
struct Judge<'a> {
owner: &'a PublicKey,
community_id: &'a CommunityId,
roster: &'a Roster,
floors: &'a Floors,
}
fn authorized_head<'a>(
judge: &Judge<'_>,
entity: [u8; 32],
candidates: &[&'a ParsedEdition],
permission: u64,
gapped: &mut bool,
) -> Option<&'a ParsedEdition> {
let authorized: Vec<&ParsedEdition> = candidates
.iter()
.copied()
.filter(|edition| {
// A banned npub's edits are dropped even while a grant naming them still carries the bit.
!judge.roster.banned.contains(&edition.author)
&& judge
.roster
.roles
.is_authorized(&edition.author, judge.owner, permission)
&& citation_ok(
judge.owner,
judge.community_id,
&edition.author,
edition.citation.as_ref(),
&judge.roster.floors,
)
})
.collect();
if authorized.is_empty() {
return None;
}
let metas: Vec<EditionMeta> = authorized
.iter()
.map(|edition| EditionMeta::from(*edition))
.collect();
let selection = fold_head(&metas, judge.floors.get(&entity));
*gapped |= selection.gap;
selection.head.map(|index| authorized[index])
}
fn seal_edition(
edition: &UnsignedEvent,
owner: &Keys,
@@ -216,42 +467,53 @@ mod tests {
use nostr_memory::MemoryDatabase;
use super::*;
use crate::edition::{EditionMeta, fold};
use crate::derive::grant_locator;
use crate::edition::fold;
use crate::roles::{Grant, Role, RoleScope};
use crate::store::{CommunityState, load_state, save_state};
use crate::{Extra, RoleId};
const AT: u64 = 1_700_000_000;
fn holder(minted: &CommunityGenesis) -> (GroupKey, GroupKey) {
let community_id = minted.identity.community_id;
(
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"),
control_signer_group_key(&minted.control_root, &community_id, ROOT_EPOCH)
.expect("derives"),
)
}
fn open_all(wraps: &[Event], read: &GroupKey, address: &PublicKey) -> Vec<ParsedEdition> {
wraps
.iter()
.map(|wrap| open_edition(wrap, read, address, true).expect("opens"))
.collect()
}
fn metadata(name: &str) -> CommunityMetadata {
CommunityMetadata {
name: name.to_owned(),
..CommunityMetadata::default()
}
}
#[test]
fn genesis_reopens_for_a_second_holder() {
let owner = Keys::generate();
let metadata = CommunityMetadata {
let community_metadata = CommunityMetadata {
name: "coop".to_owned(),
relays: vec!["wss://relay.example".to_owned()],
..CommunityMetadata::default()
};
let at_secs = 1_700_000_000;
let minted = genesis(&owner, &metadata, at_secs).expect("mints");
let minted = genesis(&owner, &community_metadata, AT).expect("mints");
assert!(minted.identity.verify(), "identity is self-certifying");
// The second client holds only what an invite hands over: the roots,
// the community id and the owner salt.
let read = control_group_key(
&minted.community_root,
&minted.identity.community_id,
ROOT_EPOCH,
)
.expect("derives");
let address = control_signer_group_key(
&minted.control_root,
&minted.identity.community_id,
ROOT_EPOCH,
)
.expect("derives")
.pk();
let mut editions = Vec::new();
for wrap in &minted.wraps {
editions.push(open_edition(wrap, &read, &address, true).expect("opens"));
}
// Only what an invite hands over: the roots, the community id and the owner salt.
let (read, signer) = holder(&minted);
let editions = open_all(&minted.wraps, &read, &signer.pk());
assert_eq!(editions.len(), 2);
@@ -280,8 +542,7 @@ mod tests {
);
}
let state =
CommunityState::from_genesis(&minted, &editions, at_secs * 1_000).expect("projects");
let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects");
smol::block_on(async {
let database = MemoryDatabase::unbounded();
@@ -297,4 +558,257 @@ mod tests {
assert_eq!(loaded.heads.len(), 2);
});
}
#[test]
fn metadata_and_channel_edits_reach_a_second_client() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
let genesis_editions = open_all(&minted.wraps, &read, &signer.pk());
let roster = fold_control(
&owner_pk,
&community_id,
&genesis_editions,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
roster.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop")
);
let writer = ControlWriter {
author: owner_pk,
read: read.clone(),
signer: signer.clone(),
};
let community_head = roster.floors.get(community_id.as_bytes()).expect("head");
let channel_head = roster
.floors
.get(minted.channel_id.as_bytes())
.expect("head");
let (community_wrap, _) = writer
.set_community_metadata(
&owner,
&community_id,
&CommunityMetadata {
relays: vec!["wss://relay.example".to_owned()],
..metadata("coop two")
},
Some(community_head),
AT + 1,
)
.expect("publishes");
let (channel_wrap, _) = writer
.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "lobby".to_owned(),
private: false,
..ChannelMetadata::default()
},
Some(channel_head),
AT + 2,
)
.expect("publishes");
let mut edited = genesis_editions.clone();
edited.extend(open_all(
&[community_wrap, channel_wrap],
&read,
&signer.pk(),
));
let folded = fold_control(
&owner_pk,
&community_id,
&edited,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
folded.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop two")
);
assert_eq!(
folded
.channels
.get(&minted.channel_id)
.map(|channel| channel.name.as_str()),
Some("lobby")
);
// A relay serving only the editions a client already folded past must not walk
// the community backwards.
let stale = fold_control(
&owner_pk,
&community_id,
&genesis_editions,
&folded.floors,
&BTreeSet::new(),
);
assert!(stale.community.is_none());
assert!(stale.channels.is_empty());
let mut state =
CommunityState::from_genesis(&minted, &genesis_editions, AT * 1_000).expect("projects");
state.apply_fold(&folded);
assert_eq!(state.channels.len(), 1);
assert_eq!(state.channels[0].name, "lobby");
assert_eq!(state.relays.len(), 1);
}
#[test]
fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
let owner = Keys::generate();
let member = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
let writer = ControlWriter {
author: owner_pk,
read: read.clone(),
signer: signer.clone(),
};
let role_id = RoleId::from_bytes([0x07; 32]);
let role = Role {
role_id,
name: "Mod".to_owned(),
position: 1,
permissions: Permissions(Permissions::MANAGE_METADATA),
scope: RoleScope::Server,
color: 0,
extra: Extra::default(),
};
let (role_wrap, _) = writer
.publish(
&owner,
Edition {
subkind: vsk::ROLE,
entity: *role_id.as_bytes(),
content: &role.to_content().expect("serializes"),
head: None,
citation: None,
},
AT + 1,
)
.expect("publishes");
let (grant_wrap, _) = writer
.publish(
&owner,
Edition {
subkind: vsk::GRANT,
entity: grant_locator(&community_id, &member.public_key().to_bytes()),
content: &Grant {
member: member.public_key(),
role_ids: vec![role_id],
control_wrap: None,
extra: Extra::default(),
}
.to_content()
.expect("serializes"),
head: None,
citation: None,
},
AT + 2,
)
.expect("publishes");
let mut base = open_all(&minted.wraps, &read, &signer.pk());
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
let roster = fold_control(
&owner_pk,
&community_id,
&base,
&Floors::new(),
&BTreeSet::new(),
);
assert!(roster.roles.is_staff(&member.public_key(), &owner_pk));
let grant = roster
.floors
.get(&grant_locator(
&community_id,
&member.public_key().to_bytes(),
))
.expect("the member's grant folded");
let head = roster.floors.get(community_id.as_bytes()).expect("head");
// The member seals with their own keys and wraps with the staff write key.
let member_writer = ControlWriter {
author: member.public_key(),
read,
signer: signer.clone(),
};
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
let (uncited, _) = member_writer
.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: None,
},
AT + 3,
)
.expect("publishes");
let (cited, _) = member_writer
.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: Some(AuthorityCitation {
entity: grant.entity,
version: grant.version,
hash: grant.self_hash,
}),
},
AT + 4,
)
.expect("publishes");
// Uncited, the edit claims an authority the member never showed.
let mut forged = base.clone();
forged.extend(open_all(&[uncited], &member_writer.read, &signer.pk()));
let folded = fold_control(
&owner_pk,
&community_id,
&forged,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
folded.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop")
);
let mut edited_editions = base;
edited_editions.extend(open_all(&[cited], &member_writer.read, &signer.pk()));
let folded = fold_control(
&owner_pk,
&community_id,
&edited_editions,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
folded.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop by mod")
);
}
}
+2 -5
View File
@@ -181,7 +181,8 @@ pub fn control_signer_group_key(
}
/// Member-writable, unlike the Control Plane:
/// a join or a leave is each member's own word.
///
/// - A join or a leave is each member's own word.
pub fn guestbook_group_key(
community_root: &[u8; 32],
community_id: &CommunityId,
@@ -210,7 +211,6 @@ pub fn channel_rekey_group_key(
)
}
/// Keyed by the prior `community_root`: the base has no stable key above it
pub fn base_rekey_group_key(
prior_root: &[u8; 32],
community_id: &CommunityId,
@@ -224,13 +224,10 @@ pub fn base_rekey_group_key(
)
}
/// Keyed by the `community_id` alone, so every member past or present resolves
/// the same address and a Refounding cannot strand the grave.
pub fn dissolved_group_key(community_id: &CommunityId) -> Result<GroupKey> {
GroupKey::derive(LABEL_DISSOLVED, community_id.as_bytes(), &ZERO32, None)
}
/// A plain SHA-256 commitment
pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId {
let mut hasher = Sha256::new();
hasher.update(LABEL_COMMUNITY.as_bytes());
+167
View File
@@ -3,6 +3,7 @@ use std::fmt;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::{EventId, PublicKey, Tag, UnsignedEvent};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::decode_hex_32;
@@ -312,6 +313,92 @@ pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
.map(|(index, _)| index)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HeadSelection {
pub head: Option<usize>,
pub gap: bool,
}
/// The head to prefer for one entity, given what this client already committed to.
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection {
let Some(floor) = floor else {
return HeadSelection {
head: bootstrap_head(editions),
gap: false,
};
};
let anchored = fold(editions, floor.version, Some(&floor.self_hash));
if anchored.anchored {
return HeadSelection {
head: anchored.head,
gap: anchored.gap,
};
}
if anchored.head.is_none() && !anchored.gap {
return HeadSelection::default();
}
let fork = editions
.iter()
.enumerate()
.filter(|(_, edition)| edition.version == floor.version)
.min_by_key(|(_, edition)| edition.tiebreak_id);
let winner = match fork {
Some((_, edition))
if edition.self_hash != floor.self_hash && edition.tiebreak_id < floor.rumor_id =>
{
edition.self_hash
}
_ => {
return HeadSelection {
head: None,
gap: true,
};
}
};
let refolded = fold(editions, floor.version, Some(&winner));
if refolded.anchored {
HeadSelection {
head: refolded.head,
gap: refolded.gap,
}
} else {
HeadSelection {
head: None,
gap: true,
}
}
}
/// A committed head, and the refuse-downgrade floor a later fold is judged against.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntityHead {
pub entity: [u8; 32],
pub version: u64,
pub self_hash: [u8; 32],
pub rumor_id: EventId,
}
impl From<&ParsedEdition> for EntityHead {
fn from(edition: &ParsedEdition) -> Self {
Self {
entity: edition.entity,
version: edition.version,
self_hash: edition.self_hash,
rumor_id: edition.rumor_id,
}
}
}
/// Every entity's committed head, keyed by coordinate.
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
fn canonical_decimal(raw: &str) -> Option<u64> {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
@@ -366,6 +453,86 @@ fn value<'a>(
mod tests {
use super::*;
fn meta(version: u64, prev: Option<[u8; 32]>, hash: u8, tiebreak: u8) -> EditionMeta {
EditionMeta {
version,
self_hash: [hash; 32],
prev,
tiebreak_id: EventId::from_byte_array([tiebreak; 32]),
}
}
fn head(version: u64, hash: u8, rumor: u8) -> EntityHead {
EntityHead {
entity: [0x11; 32],
version,
self_hash: [hash; 32],
rumor_id: EventId::from_byte_array([rumor; 32]),
}
}
#[test]
fn fold_picks_the_head_from_the_chain_and_the_floor() {
let chain = [
meta(1, None, 0xa1, 1),
meta(2, Some([0xa1; 32]), 0xa2, 2),
meta(3, Some([0xa2; 32]), 0xa3, 3),
];
let folded = fold(&chain, 0, None);
assert_eq!(folded.head, Some(2));
assert!(!folded.gap && folded.anchored);
// A missing link stops the walk at the last contiguous edition.
let gapped = fold(&[chain[0], chain[2]], 0, None);
assert_eq!(gapped.head, Some(0));
assert!(gapped.gap && gapped.anchored);
// Everything below the held floor is a stale relay, not a gap.
let stale = fold(&chain[..2], 3, Some(&[0xa3; 32]));
assert_eq!(stale.head, None);
assert!(!stale.gap && !stale.anchored);
// A fork at a version breaks on the lower inner rumor id, and the chain resumes.
let fork = [meta(1, None, 0xb1, 9), meta(1, None, 0xa1, 1)];
assert_eq!(
fold(&fork, 0, None).head,
Some(1),
"the lower rumor id wins"
);
let forked = [fork[0], fork[1], chain[1], chain[2]];
assert_eq!(fold(&forked, 0, None).head, Some(3));
// A re-wrap onto the head we hold is the legitimate case; one whose `prev` no
// longer resolves is a withholding.
let rewrapped = meta(5, Some([0x99; 32]), 0xc5, 5);
assert_eq!(
fold_head(&[rewrapped], Some(&head(4, 0x99, 4))).head,
Some(0)
);
let dangling = meta(5, Some([0x88; 32]), 0xc5, 5);
let refused = fold_head(&[dangling], Some(&head(4, 0x99, 4)));
assert_eq!(refused.head, None);
assert!(refused.gap);
// A bootstrap takes it anyway: a compaction would leave a joiner with nothing.
assert_eq!(bootstrap_head(&[dangling]), Some(0));
assert_eq!(fold_head(&[dangling], None).head, Some(0));
// A fork at the floor's own version converges to the lower rumor id when that is
// genuinely earlier than what we hold, and the chain above it re-anchors.
let forked = [
meta(2, Some([0xa1; 32]), 0xb2, 3),
meta(3, Some([0xb2; 32]), 0xb3, 4),
];
let converged = fold_head(&forked, Some(&head(2, 0xaa, 9)));
assert_eq!(converged.head, Some(1));
assert!(!converged.gap);
// A fork that is not earlier than the held head is refused.
assert_eq!(fold_head(&forked, Some(&head(2, 0xaa, 2))).head, None);
}
#[test]
fn edition_hash_matches_the_cross_client_vector() {
let entity = [0x11u8; 32];
+12 -7
View File
@@ -1,6 +1,7 @@
pub mod control;
pub mod derive;
pub mod edition;
pub mod roles;
pub mod store;
pub mod stream;
@@ -14,6 +15,9 @@ use rand::TryRng as _;
use rand::rngs::SysRng;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Unknown fields a content struct does not model, so a republish cannot wipe them.
pub(crate) type Extra = serde_json::Map<String, serde_json::Value>;
macro_rules! hex_id {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
@@ -76,20 +80,21 @@ macro_rules! hex_id {
}
hex_id! {
/// A Community's permanent identity: a self-certifying commitment to its
/// owner's key. It travels inside invites and is itself never on the wire
/// (CORD-02 §1).
/// A self-certifying commitment to the owner's key, carried inside invites and
/// never on the wire.
CommunityId
}
hex_id! {
/// A Channel's identity within its Community (CORD-03).
ChannelId
}
/// A key-rotation counter attached to each Community key.
///
/// It bumps only on a Rekey, a membership change where somebody is removed.
hex_id! {
/// Both a Role's entity coordinate and the field it repeats in its own content.
RoleId
}
/// A key-rotation counter; it bumps only on a Rekey that removes somebody.
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize,
)]
File diff suppressed because it is too large Load Diff
+47 -11
View File
@@ -5,9 +5,11 @@ use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::control::{ChannelMetadata, CommunityGenesis, CommunityMetadata, ROOT_EPOCH};
use crate::control::{
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
};
use crate::derive::control_signer_group_key;
use crate::edition::{ParsedEdition, vsk};
use crate::edition::{EntityHead, Floors, ParsedEdition, vsk};
use crate::stream::OpenedStream;
use crate::{ChannelId, CommunityId, Epoch};
@@ -45,7 +47,6 @@ pub async fn cache_rumor(
Ok(())
}
/// Read a channel's cached rumors.
pub async fn query_rumors(
database: &dyn NostrDatabase,
channel: &ChannelId,
@@ -89,14 +90,6 @@ pub async fn query_rumors(
Ok(rumors)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntityHead {
pub entity: [u8; 32],
pub version: u64,
pub self_hash: [u8; 32],
pub rumor_id: EventId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelKeyRef {
pub id: ChannelId,
@@ -194,6 +187,49 @@ impl CommunityState {
pub fn identifier(&self) -> String {
state_identifier(&self.id)
}
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();
if let Some(community) = &fold.community {
self.relays = community
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok())
.collect();
}
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,
}),
None => {}
}
}
}
}
fn state_identifier(id: &CommunityId) -> String {
-2
View File
@@ -140,7 +140,6 @@ pub fn build_rumor_secs(
rumor
}
/// Resolve a rumor's true millisecond time.
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError> {
let seconds = rumor.created_at.as_secs().saturating_mul(1000);
let mut tag: Option<Option<String>> = None;
@@ -215,7 +214,6 @@ pub fn wrap_seal(
)
}
/// Signs with `signer` while encrypting under `conversation`.
pub fn wrap_seal_with(
seal: &Event,
conversation: &ConversationKey,