use std::collections::BTreeMap; 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; use crate::stream::build_rumor_secs; pub const KIND_CONTROL: u16 = 3308; const EDITION_LABEL: &[u8] = b"vector-community/v1/edition"; /// Entity types an edition can address (CORD-02 Appendix B). pub mod vsk { pub const COMMUNITY_METADATA: &str = "0"; pub const ROLE: &str = "1"; pub const CHANNEL_METADATA: &str = "2"; pub const GRANT: &str = "3"; pub const BANLIST: &str = "4"; pub const INVITE_LIVE: &str = "6"; pub const INVITE_LINKS: &str = "8"; pub const INVITE_REVOKED: &str = "9"; pub const DISSOLVED: &str = "10"; pub const PINS: &str = "11"; } const TAG_SUBKIND: &str = "vsk"; const TAG_ENTITY: &str = "eid"; const TAG_VERSION: &str = "ev"; const TAG_PREV: &str = "ep"; const TAG_CITATION: &str = "vac"; #[derive(Debug)] pub enum EditionError { BadKind(u16), BadField(&'static str), Duplicate(&'static str), Missing(&'static str), } impl fmt::Display for EditionError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { EditionError::BadKind(kind) => write!(f, "not an edition kind: {kind}"), EditionError::BadField(name) => write!(f, "malformed edition field: {name}"), EditionError::Duplicate(name) => write!(f, "duplicate edition field: {name}"), EditionError::Missing(name) => write!(f, "missing edition field: {name}"), } } } impl std::error::Error for EditionError {} /// A `vac` citation: the Grant edition an actor claims rank under, pinned by /// coordinate, version and hash. It is a sync floor, not the verdict. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AuthorityCitation { pub entity: [u8; 32], pub version: u64, pub hash: [u8; 32], } #[derive(Debug, Clone)] pub struct ParsedEdition { pub author: PublicKey, pub subkind: String, pub entity: [u8; 32], pub version: u64, pub prev: Option<[u8; 32]>, pub citation: Option, pub content: String, pub self_hash: [u8; 32], pub rumor_id: EventId, } pub struct EditionFields<'a> { pub author: PublicKey, pub subkind: &'a str, pub entity: [u8; 32], pub version: u64, pub prev: Option<[u8; 32]>, pub citation: Option, pub content: &'a str, pub at_secs: u64, } fn signing_bytes( entity: &[u8; 32], version: u64, prev: Option<&[u8; 32]>, content: &[u8], ) -> Vec { let mut bytes = Vec::with_capacity(8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + content.len()); bytes.extend_from_slice(&(EDITION_LABEL.len() as u64).to_be_bytes()); bytes.extend_from_slice(EDITION_LABEL); bytes.extend_from_slice(entity); bytes.extend_from_slice(&version.to_be_bytes()); match prev { Some(prev) => { bytes.push(1); bytes.extend_from_slice(prev); } None => { bytes.push(0); bytes.extend_from_slice(&[0u8; 32]); } } bytes.extend_from_slice(&(content.len() as u64).to_be_bytes()); bytes.extend_from_slice(content); bytes } pub fn edition_hash( entity: &[u8; 32], version: u64, prev: Option<&[u8; 32]>, content: &[u8], ) -> [u8; 32] { Sha256::digest(signing_bytes(entity, version, prev, content)).into() } pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent { let mut tags = vec![ Tag::custom(TAG_SUBKIND, [fields.subkind]), Tag::custom(TAG_ENTITY, [HEXLOWER.encode(&fields.entity)]), Tag::custom(TAG_VERSION, [fields.version.to_string()]), ]; if let Some(prev) = fields.prev { tags.push(Tag::custom(TAG_PREV, [HEXLOWER.encode(&prev)])); } if let Some(citation) = fields.citation { tags.push(Tag::custom( TAG_CITATION, [ HEXLOWER.encode(&citation.entity), citation.version.to_string(), HEXLOWER.encode(&citation.hash), ], )); } build_rumor_secs( KIND_CONTROL, fields.author, fields.content, tags, fields.at_secs, ) } pub fn parse_edition(rumor: &UnsignedEvent) -> Result { let kind = rumor.kind.as_u16(); if kind != KIND_CONTROL { return Err(EditionError::BadKind(kind)); } let subkind = value(rumor, TAG_SUBKIND)? .ok_or(EditionError::Missing(TAG_SUBKIND))? .to_owned(); if canonical_decimal(&subkind).is_none() { return Err(EditionError::BadField(TAG_SUBKIND)); } let entity = hex32( value(rumor, TAG_ENTITY)?.ok_or(EditionError::Missing(TAG_ENTITY))?, TAG_ENTITY, )?; let version = canonical_decimal(value(rumor, TAG_VERSION)?.ok_or(EditionError::Missing(TAG_VERSION))?) .ok_or(EditionError::BadField(TAG_VERSION))?; let prev = match value(rumor, TAG_PREV)? { Some(raw) => Some(hex32(raw, TAG_PREV)?), None => None, }; let citation = match fields(rumor, TAG_CITATION)? { Some(fields) if fields.len() == 4 => Some(AuthorityCitation { entity: hex32(&fields[1], TAG_CITATION)?, version: canonical_decimal(&fields[2]).ok_or(EditionError::BadField(TAG_CITATION))?, hash: hex32(&fields[3], TAG_CITATION)?, }), Some(_) => return Err(EditionError::BadField(TAG_CITATION)), None => None, }; let self_hash = edition_hash(&entity, version, prev.as_ref(), rumor.content.as_bytes()); Ok(ParsedEdition { author: rumor.pubkey, subkind, entity, version, prev, citation, content: rumor.content.clone(), self_hash, rumor_id: rumor.id.unwrap_or_else(|| rumor.compute_id()), }) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct EditionMeta { pub version: u64, pub self_hash: [u8; 32], pub prev: Option<[u8; 32]>, pub tiebreak_id: EventId, } impl From<&ParsedEdition> for EditionMeta { fn from(edition: &ParsedEdition) -> Self { Self { version: edition.version, self_hash: edition.self_hash, prev: edition.prev, tiebreak_id: edition.rumor_id, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct FoldResult { pub head: Option, pub gap: bool, pub anchored: bool, } /// The highest version whose chain is intact, given a held floor. pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult { let mut by_version: BTreeMap = BTreeMap::new(); for (index, edition) in editions.iter().enumerate() { if edition.version < floor { continue; } match by_version.get(&edition.version) { Some(¤t) if editions[current].tiebreak_id <= edition.tiebreak_id => {} _ => { by_version.insert(edition.version, index); } } } let Some((&lowest_version, &lowest_index)) = by_version.first_key_value() else { return FoldResult::default(); }; let lowest = editions[lowest_index]; let anchored = if floor == 0 { lowest_version == 1 && lowest.prev.is_none() } else if lowest_version == floor { floor_hash == Some(&lowest.self_hash) } else if lowest_version == floor + 1 { floor_hash.is_some() && lowest.prev.as_ref() == floor_hash } else { false }; let mut head = Some(lowest_index); let mut gap = !anchored; let mut previous_version = lowest_version; let mut previous_hash = lowest.self_hash; for (&version, &index) in by_version.range(lowest_version + 1..) { let edition = editions[index]; if version == previous_version + 1 && edition.prev == Some(previous_hash) { head = Some(index); previous_version = version; previous_hash = edition.self_hash; } else { gap = true; break; } } FoldResult { head, gap, anchored, } } /// The highest version overall, ignoring contiguity. pub fn bootstrap_head(editions: &[EditionMeta]) -> Option { editions .iter() .enumerate() .reduce(|(best_index, best), (index, candidate)| { let supersedes = candidate.version > best.version || (candidate.version == best.version && candidate.tiebreak_id < best.tiebreak_id); if supersedes { (index, candidate) } else { (best_index, best) } }) .map(|(index, _)| index) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct HeadSelection { pub head: Option, 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>; pub(crate) fn canonical_decimal(raw: &str) -> Option { if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) { return None; } if raw.len() > 1 && raw.starts_with('0') { return None; } raw.parse().ok() } fn hex32(raw: &str, name: &'static str) -> Result<[u8; 32], EditionError> { decode_hex_32(raw).map_err(|_| EditionError::BadField(name)) } fn fields<'a>( rumor: &'a UnsignedEvent, name: &'static str, ) -> Result, EditionError> { let mut found: Option<&[String]> = None; for tag in rumor.tags.iter() { let tag_fields = tag.as_slice(); if tag_fields.first().map(String::as_str) != Some(name) { continue; } if found.is_some() { return Err(EditionError::Duplicate(name)); } found = Some(tag_fields); } Ok(found) } fn value<'a>( rumor: &'a UnsignedEvent, name: &'static str, ) -> Result, EditionError> { match fields(rumor, name)? { Some(fields) if fields.len() == 2 => Ok(Some(fields[1].as_str())), Some(_) => Err(EditionError::BadField(name)), None => Ok(None), } } #[cfg(test)] 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]; assert_eq!( HEXLOWER.encode(&edition_hash(&entity, 1, None, b"hello")), "2daf42e65a6bc259a4c99fac6df754a5d3d92310607cf13e2a1e8c94d42f6303" ); // The golden vector only exercises the absent-prev encoding; pin the // present-prev branch structurally so a swapped flag stays visible. let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello"); assert_eq!( bytes.len(), 8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + 5 ); assert_eq!(&bytes[8..8 + EDITION_LABEL.len()], EDITION_LABEL); assert_eq!( bytes[8 + EDITION_LABEL.len() + 32..][..8], 1u64.to_be_bytes() ); assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1); } }