1use nostr_sdk::prelude::nip44::v2::ConversationKey;
23use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, Timestamp, UnsignedEvent};
24use serde::{Deserialize, Serialize};
25
26use super::super::edition::{AuthorityCitation, EditionError, ParsedEdition, TAG_AUTHORITY_CITATION};
27use super::super::{version, ChannelId, CommunityId, Epoch};
28use super::derive::{control_group_key, control_signer_group_key, verify_community_id, GroupKey};
29use super::stream::{self, OpenedStream, SealForm, StreamError};
30use super::{kind, vsk};
31
32const TAG_SUBKIND: &str = "vsk";
33const TAG_ENTITY: &str = "eid";
34const TAG_EVERSION: &str = "ev";
35const TAG_EPREV: &str = "ep";
36
37pub const MAX_NAME_BYTES: usize = 64;
39pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
41
42#[derive(Debug)]
44pub enum ControlError {
45 Stream(StreamError),
46 Edition(EditionError),
47 NotAnEdition(u16),
49 NotPlaintextSealed,
53 OverCap(&'static str),
55}
56
57impl std::fmt::Display for ControlError {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 match self {
60 ControlError::Stream(e) => write!(f, "stream: {e}"),
61 ControlError::Edition(e) => write!(f, "edition: {e:?}"),
62 ControlError::NotAnEdition(k) => write!(f, "rumor kind {k} is not a control edition"),
63 ControlError::NotPlaintextSealed => write!(f, "control edition must ride a plaintext seal"),
64 ControlError::OverCap(what) => write!(f, "{what} exceeds its byte cap"),
65 }
66 }
67}
68
69impl std::error::Error for ControlError {}
70
71impl From<StreamError> for ControlError {
72 fn from(e: StreamError) -> Self {
73 ControlError::Stream(e)
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct CommunityIdentity {
83 pub community_id: CommunityId,
84 pub owner_xonly: [u8; 32],
85 pub owner_salt: [u8; 32],
86}
87
88impl CommunityIdentity {
89 pub fn mint(owner: &PublicKey) -> CommunityIdentity {
91 let owner_xonly = owner.to_bytes();
92 let owner_salt = super::super::random_32();
93 CommunityIdentity {
94 community_id: super::derive::community_id_of(&owner_xonly, &owner_salt),
95 owner_xonly,
96 owner_salt,
97 }
98 }
99
100 pub fn verify(&self) -> bool {
102 verify_community_id(&self.community_id, &self.owner_xonly, &self.owner_salt)
103 }
104
105 pub fn owner(&self) -> Result<PublicKey, String> {
107 PublicKey::from_slice(&self.owner_xonly).map_err(|e| e.to_string())
108 }
109}
110
111#[allow(clippy::too_many_arguments)]
116pub fn build_edition_rumor(
117 author: PublicKey,
118 vsk: &str,
119 entity_id: &[u8; 32],
120 version: u64,
121 prev_hash: Option<&[u8; 32]>,
122 content: &str,
123 created_at_secs: u64,
124 authority: Option<&AuthorityCitation>,
125) -> UnsignedEvent {
126 let mut tags = vec![
127 Tag::custom(TAG_SUBKIND, [vsk.to_string()]),
128 Tag::custom(TAG_ENTITY, [crate::simd::hex::bytes_to_hex_32(entity_id)]),
129 Tag::custom(TAG_EVERSION, [version.to_string()]),
130 ];
131 if let Some(p) = prev_hash {
132 tags.push(Tag::custom(TAG_EPREV, [crate::simd::hex::bytes_to_hex_32(p)]));
133 }
134 if let Some(a) = authority {
135 tags.push(a.to_tag());
136 }
137 stream::build_rumor_secs(kind::CONTROL, author, content, tags, created_at_secs)
138}
139
140pub fn parse_edition_rumor(rumor: &UnsignedEvent) -> Result<ParsedEdition, ControlError> {
145 if rumor.kind.as_u16() != kind::CONTROL {
146 return Err(ControlError::NotAnEdition(rumor.kind.as_u16()));
147 }
148 for name in [TAG_SUBKIND, TAG_ENTITY, TAG_EVERSION, TAG_EPREV, TAG_AUTHORITY_CITATION] {
151 let count = rumor
152 .tags
153 .iter()
154 .filter(|t| t.as_slice().first().map(|s| s.as_str() == name).unwrap_or(false))
155 .count();
156 if count > 1 {
157 return Err(ControlError::Edition(EditionError::BadField("duplicate authority tag")));
158 }
159 }
160 let get = |name: &str| -> Option<String> {
161 rumor.tags.iter().find_map(|t| {
162 let s = t.as_slice();
163 (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
164 })
165 };
166 let decode_hash = |hex: &str, field: &'static str| -> Result<[u8; 32], ControlError> {
167 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
168 return Err(ControlError::Edition(EditionError::BadField(field)));
169 }
170 Ok(crate::simd::hex::hex_to_bytes_32(hex))
171 };
172 let vsk = get(TAG_SUBKIND).ok_or(ControlError::Edition(EditionError::MissingField("vsk")))?;
173 let entity_id = decode_hash(&get(TAG_ENTITY).ok_or(ControlError::Edition(EditionError::MissingField("eid")))?, "eid")?;
174 let ev_raw = get(TAG_EVERSION).ok_or(ControlError::Edition(EditionError::MissingField("ev")))?;
179 if !crate::community::edition::is_tag_decimal(&ev_raw) {
180 return Err(ControlError::Edition(EditionError::BadField("ev")));
181 }
182 let version: u64 = ev_raw.parse().map_err(|_| ControlError::Edition(EditionError::BadField("ev")))?;
183 let prev_hash = match get(TAG_EPREV) {
184 Some(h) => Some(decode_hash(&h, "ep")?),
185 None => None,
186 };
187 let self_hash = version::edition_hash(&entity_id, version, prev_hash.as_ref(), rumor.content.as_bytes());
188 Ok(ParsedEdition {
189 author: rumor.pubkey,
190 vsk,
191 entity_id,
192 version,
193 prev_hash,
194 content: rumor.content.clone(),
195 self_hash,
196 created_at: rumor.created_at.as_secs(),
197 inner_id: rumor.id.expect("verified rumors carry their id").to_bytes(),
198 authority: AuthorityCitation::from_tags(&rumor.tags),
199 })
200}
201
202#[derive(Clone)]
213pub struct ControlPlane {
214 pk: PublicKey,
215 conv_key: ConversationKey,
216 signer: Option<Keys>,
217 restricted: bool,
218}
219
220impl ControlPlane {
221 pub fn of(community: &super::community::CommunityV2) -> ControlPlane {
223 let read = control_group_key(&community.community_root, community.id(), community.root_epoch);
224 match community.control_pk {
225 None => ControlPlane {
226 pk: read.pk(),
227 conv_key: read.conv_key().clone(),
228 signer: Some(read.keys().clone()),
229 restricted: false,
230 },
231 Some(pk) => {
232 let signer = community.control_root.and_then(|cr| {
236 let s = control_signer_group_key(&cr, community.id(), community.root_epoch);
237 (s.pk() == pk).then(|| s.keys().clone())
238 });
239 ControlPlane { pk, conv_key: read.conv_key().clone(), signer, restricted: true }
240 }
241 }
242 }
243
244 pub fn legacy(group: &GroupKey) -> ControlPlane {
246 ControlPlane {
247 pk: group.pk(),
248 conv_key: group.conv_key().clone(),
249 signer: Some(group.keys().clone()),
250 restricted: false,
251 }
252 }
253
254 pub fn pk(&self) -> PublicKey {
256 self.pk
257 }
258
259 pub fn pk_hex(&self) -> String {
260 self.pk.to_hex()
261 }
262
263 pub fn can_write(&self) -> bool {
265 self.signer.is_some()
266 }
267
268 pub fn signer_keys(&self) -> Option<&Keys> {
271 self.signer.as_ref()
272 }
273
274 pub fn write_group(&self) -> Result<GroupKey, String> {
279 match &self.signer {
280 Some(keys) => Ok(GroupKey::from_parts(keys.clone(), self.conv_key.clone())),
281 None => Err("only community staff hold this community's write key; ask an admin to re-send your promotion".to_string()),
282 }
283 }
284
285 pub fn open(&self, wrap: &Event) -> Result<(ParsedEdition, OpenedStream), ControlError> {
289 let opened = stream::open_wrap_at(wrap, &self.pk, &self.conv_key, self.restricted)?;
290 if opened.seal_form != SealForm::Plaintext {
291 return Err(ControlError::NotPlaintextSealed);
292 }
293 let edition = parse_edition_rumor(&opened.rumor)?;
294 Ok((edition, opened))
295 }
296}
297
298impl std::fmt::Debug for ControlPlane {
299 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301 f.debug_struct("ControlPlane")
302 .field("pk", &self.pk_hex())
303 .field("restricted", &self.restricted)
304 .field("writable", &self.can_write())
305 .finish()
306 }
307}
308
309pub fn split_write_group(control_root: &[u8; 32], community_root: &[u8; 32], community_id: &CommunityId, epoch: Epoch) -> GroupKey {
313 let signer = control_signer_group_key(control_root, community_id, epoch);
314 let read = control_group_key(community_root, community_id, epoch);
315 GroupKey::from_parts(signer.keys().clone(), read.conv_key().clone())
316}
317
318pub fn seal_control_edition(
324 rumor: &UnsignedEvent,
325 group: &GroupKey,
326 author_keys: &Keys,
327 wrap_at: Timestamp,
328) -> Result<(Event, Keys), ControlError> {
329 let seal = stream::build_seal(rumor, SealForm::Plaintext, group, author_keys)?;
330 Ok(stream::wrap_seal(&seal, group, stream::KIND_WRAP, wrap_at)?)
331}
332
333pub async fn seal_control_edition_signed<S: crate::signer::VectorSigner + ?Sized>(
337 signer: &S,
338 author: PublicKey,
339 rumor: &UnsignedEvent,
340 group: &GroupKey,
341 wrap_at: Timestamp,
342) -> Result<(Event, Keys), ControlError> {
343 Ok(stream::seal_and_wrap_signed(signer, author, rumor, SealForm::Plaintext, group, stream::KIND_WRAP, wrap_at, &[]).await?)
344}
345
346pub fn open_control_edition(wrap: &Event, group: &GroupKey) -> Result<(ParsedEdition, OpenedStream), ControlError> {
349 let opened = stream::open_wrap(wrap, group)?;
350 if opened.seal_form != SealForm::Plaintext {
351 return Err(ControlError::NotPlaintextSealed);
352 }
353 let edition = parse_edition_rumor(&opened.rumor)?;
354 Ok((edition, opened))
355}
356
357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
362pub struct ImageRef {
363 pub url: String,
364 pub key: String,
365 pub nonce: String,
366 pub hash: String,
367 #[serde(flatten)]
370 pub extra: serde_json::Map<String, serde_json::Value>,
371}
372
373impl ImageRef {
374 pub fn to_community_image(&self) -> crate::community::CommunityImage {
378 let ext = self
379 .extra
380 .get("ext")
381 .and_then(|v| v.as_str())
382 .filter(|s| !s.is_empty())
383 .map(str::to_string)
384 .or_else(|| {
385 std::path::Path::new(&self.url)
386 .extension()
387 .and_then(|e| e.to_str())
388 .map(|s| s.to_lowercase())
389 })
390 .unwrap_or_else(|| "png".to_string());
391 crate::community::CommunityImage {
392 url: self.url.clone(),
393 key: self.key.clone(),
394 nonce: self.nonce.clone(),
395 hash: self.hash.clone(),
396 ext,
397 }
398 }
399}
400
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
404pub struct CommunityMetadata {
405 pub name: String,
406 #[serde(skip_serializing_if = "Option::is_none")]
407 pub description: Option<String>,
408 #[serde(default, skip_serializing_if = "Vec::is_empty")]
409 pub relays: Vec<String>,
410 #[serde(skip_serializing_if = "Option::is_none")]
411 pub icon: Option<ImageRef>,
412 #[serde(skip_serializing_if = "Option::is_none")]
413 pub banner: Option<ImageRef>,
414 #[serde(skip_serializing_if = "Option::is_none")]
416 pub custom: Option<serde_json::Map<String, serde_json::Value>>,
417 #[serde(flatten)]
419 pub extra: serde_json::Map<String, serde_json::Value>,
420}
421
422#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
426pub struct ChannelMetadata {
427 pub name: String,
428 pub private: bool,
429 #[serde(skip_serializing_if = "Option::is_none")]
430 pub voice: Option<bool>,
431 #[serde(skip_serializing_if = "Option::is_none")]
432 pub deleted: Option<bool>,
433 #[serde(skip_serializing_if = "Option::is_none")]
434 pub custom: Option<serde_json::Map<String, serde_json::Value>>,
435 #[serde(flatten)]
436 pub extra: serde_json::Map<String, serde_json::Value>,
437}
438
439pub fn validate_community_metadata(meta: &CommunityMetadata) -> Result<(), ControlError> {
442 if meta.name.len() > MAX_NAME_BYTES {
443 return Err(ControlError::OverCap("name"));
444 }
445 if meta.description.as_ref().is_some_and(|d| d.len() > MAX_DESCRIPTION_BYTES) {
446 return Err(ControlError::OverCap("description"));
447 }
448 Ok(())
449}
450
451pub fn validate_channel_metadata(meta: &ChannelMetadata) -> Result<(), ControlError> {
452 if meta.name.len() > MAX_NAME_BYTES {
453 return Err(ControlError::OverCap("name"));
454 }
455 Ok(())
456}
457
458pub struct Genesis {
464 pub identity: CommunityIdentity,
465 pub community_root: [u8; 32],
467 pub control_root: [u8; 32],
470 pub general_channel_id: ChannelId,
471 pub wraps: [Event; 2],
473}
474
475impl Genesis {
476 pub fn control_pk(&self) -> PublicKey {
478 control_signer_group_key(&self.control_root, &self.identity.community_id, Epoch(0)).pk()
479 }
480}
481
482pub fn genesis(owner_keys: &Keys, mut metadata: CommunityMetadata, at_secs: u64) -> Result<Genesis, ControlError> {
488 validate_community_metadata(&metadata)?;
489 metadata.relays.truncate(super::super::MAX_COMMUNITY_RELAYS);
491
492 let identity = CommunityIdentity::mint(&owner_keys.public_key());
493 let community_root = super::super::random_32();
494 let control_root = super::super::random_32();
495 let general_channel_id = ChannelId(super::super::random_32());
496 let group = split_write_group(&control_root, &community_root, &identity.community_id, Epoch(0));
497
498 let meta_json = serde_json::to_string(&metadata).map_err(|e| ControlError::Stream(StreamError::Parse(e.to_string())))?;
499 let meta_rumor = build_edition_rumor(
500 owner_keys.public_key(),
501 vsk::COMMUNITY_METADATA,
502 &identity.community_id.0,
503 1,
504 None,
505 &meta_json,
506 at_secs,
507 None,
508 );
509
510 let general = ChannelMetadata { name: "general".into(), private: false, ..Default::default() };
511 let general_json = serde_json::to_string(&general).map_err(|e| ControlError::Stream(StreamError::Parse(e.to_string())))?;
512 let general_rumor = build_edition_rumor(
513 owner_keys.public_key(),
514 vsk::CHANNEL_METADATA,
515 &general_channel_id.0,
516 1,
517 None,
518 &general_json,
519 at_secs,
520 None,
521 );
522
523 let (meta_wrap, _) = seal_control_edition(&meta_rumor, &group, owner_keys, Timestamp::from_secs(at_secs))?;
524 let (general_wrap, _) = seal_control_edition(&general_rumor, &group, owner_keys, Timestamp::from_secs(at_secs))?;
525
526 Ok(Genesis {
527 identity,
528 community_root,
529 control_root,
530 general_channel_id,
531 wraps: [meta_wrap, general_wrap],
532 })
533}
534
535pub async fn genesis_signed<S: crate::signer::VectorSigner + ?Sized>(
539 owner_pk: PublicKey,
540 signer: &S,
541 metadata: CommunityMetadata,
542 at_secs: u64,
543) -> Result<Genesis, ControlError> {
544 genesis_signed_with_primary(owner_pk, signer, metadata, at_secs, None).await
545}
546
547pub async fn genesis_signed_with_primary<S: crate::signer::VectorSigner + ?Sized>(
552 owner_pk: PublicKey,
553 signer: &S,
554 mut metadata: CommunityMetadata,
555 at_secs: u64,
556 primary: Option<(ChannelId, String)>,
557) -> Result<Genesis, ControlError> {
558 validate_community_metadata(&metadata)?;
559 metadata.relays.truncate(super::super::MAX_COMMUNITY_RELAYS);
560
561 let identity = CommunityIdentity::mint(&owner_pk);
562 let community_root = super::super::random_32();
563 let control_root = super::super::random_32();
564 let (general_channel_id, general_name) = match primary {
565 Some((id, name)) => (id, name),
566 None => (ChannelId(super::super::random_32()), "general".to_string()),
567 };
568 let group = split_write_group(&control_root, &community_root, &identity.community_id, Epoch(0));
569
570 let meta_json = serde_json::to_string(&metadata).map_err(|e| ControlError::Stream(StreamError::Parse(e.to_string())))?;
571 let meta_rumor = build_edition_rumor(owner_pk, vsk::COMMUNITY_METADATA, &identity.community_id.0, 1, None, &meta_json, at_secs, None);
572
573 let general = ChannelMetadata { name: general_name, private: false, ..Default::default() };
574 let general_json = serde_json::to_string(&general).map_err(|e| ControlError::Stream(StreamError::Parse(e.to_string())))?;
575 let general_rumor = build_edition_rumor(owner_pk, vsk::CHANNEL_METADATA, &general_channel_id.0, 1, None, &general_json, at_secs, None);
576
577 let (meta_wrap, _) = seal_control_edition_signed(signer, owner_pk, &meta_rumor, &group, Timestamp::from_secs(at_secs)).await?;
578 let (general_wrap, _) = seal_control_edition_signed(signer, owner_pk, &general_rumor, &group, Timestamp::from_secs(at_secs)).await?;
579
580 Ok(Genesis {
581 identity,
582 community_root,
583 control_root,
584 general_channel_id,
585 wraps: [meta_wrap, general_wrap],
586 })
587}
588
589#[cfg(test)]
590mod tests {
591 use nostr_sdk::prelude::FinalizeEvent;
592 use super::super::super::edition::build_edition_inner;
593 use super::*;
594
595 fn cid() -> CommunityId {
596 CommunityId([0x33; 32])
597 }
598
599 fn group_at(epoch: u64) -> GroupKey {
600 control_group_key(&[0x44; 32], &cid(), Epoch(epoch))
601 }
602
603 fn simple_edition(author: &Keys, version: u64, prev: Option<&[u8; 32]>) -> UnsignedEvent {
604 build_edition_rumor(
605 author.public_key(),
606 vsk::GRANT,
607 &[0x55; 32],
608 version,
609 prev,
610 "{\"member\":\"aa\",\"role_ids\":[]}",
611 1_700_000_000,
612 None,
613 )
614 }
615
616 #[test]
617 fn edition_round_trips_through_the_control_plane() {
618 let owner = Keys::generate();
619 let group = group_at(0);
620 let cite = AuthorityCitation { entity_id: [0xab; 32], version: 7, edition_hash: [0xcd; 32] };
621 let rumor = build_edition_rumor(
622 owner.public_key(),
623 vsk::GRANT,
624 &[0x55; 32],
625 2,
626 Some(&[0x66; 32]),
627 "{\"member\":\"aa\",\"role_ids\":[]}",
628 1_700_000_000,
629 Some(&cite),
630 );
631 let (wrap, _) = seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(1_700_000_001)).unwrap();
632
633 let (edition, opened) = open_control_edition(&wrap, &group).unwrap();
634 assert_eq!(edition.author, owner.public_key());
635 assert_eq!(edition.vsk, vsk::GRANT);
636 assert_eq!(edition.entity_id, [0x55; 32]);
637 assert_eq!(edition.version, 2);
638 assert_eq!(edition.prev_hash, Some([0x66; 32]));
639 assert_eq!(edition.authority.as_ref(), Some(&cite));
640 assert_eq!(opened.seal_form, SealForm::Plaintext);
641 assert_eq!(
643 edition.self_hash,
644 version::edition_hash(&[0x55; 32], 2, Some(&[0x66; 32]), rumor.content.as_bytes())
645 );
646 }
647
648 #[test]
649 fn v2_edition_tags_carry_no_protocol_version_tag() {
650 let owner = Keys::generate();
653 let rumor = simple_edition(&owner, 1, None);
654 assert!(
655 !rumor.tags.iter().any(|t| t.as_slice().first().map(|s| s == "v").unwrap_or(false)),
656 "v2 editions must not carry a protocol version tag"
657 );
658 assert!(!rumor.tags.iter().any(|t| t.as_slice().first().map(|s| s == "ms").unwrap_or(false)));
660 }
661
662 #[test]
663 fn edition_hash_is_identical_across_protocols() {
664 let author = Keys::generate();
669 let entity = [0x55; 32];
670 let content = "{\"member\":\"aa\",\"role_ids\":[]}";
671 let v1_inner = build_edition_inner(author.public_key(), "3", &entity, 2, Some(&[0x66; 32]), content, 100, None)
672 .finalize(&author)
673 .unwrap();
674 let v1_parsed = super::super::super::edition::parse_edition_inner(&v1_inner).unwrap();
675
676 let v2_rumor = build_edition_rumor(author.public_key(), "3", &entity, 2, Some(&[0x66; 32]), content, 100, None);
677 let v2_parsed = parse_edition_rumor(&v2_rumor).unwrap();
678
679 assert_eq!(v1_parsed.self_hash, v2_parsed.self_hash);
680 }
681
682 #[test]
683 fn encrypted_seal_control_edition_is_rejected() {
684 let owner = Keys::generate();
685 let group = group_at(0);
686 let rumor = simple_edition(&owner, 1, None);
687 let seal = stream::build_seal(&rumor, SealForm::Encrypted, &group, &owner).unwrap();
688 let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
689 assert!(matches!(open_control_edition(&wrap, &group), Err(ControlError::NotPlaintextSealed)));
690 }
691
692 #[test]
693 fn non_edition_rumor_is_rejected() {
694 let owner = Keys::generate();
695 let group = group_at(0);
696 let rumor = stream::build_rumor_secs(super::kind::MESSAGE, owner.public_key(), "hi", vec![], 100);
697 let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group, &owner).unwrap();
698 let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
699 assert!(matches!(open_control_edition(&wrap, &group), Err(ControlError::NotAnEdition(k)) if k == super::kind::MESSAGE));
700 }
701
702 #[test]
703 fn duplicate_machinery_tags_are_rejected() {
704 let owner = Keys::generate();
705 let mut rumor = simple_edition(&owner, 1, None);
706 let dup = Tag::custom("eid", [crate::simd::hex::bytes_to_hex_32(&[0x55; 32])]);
707 let mut tags: Vec<Tag> = rumor.tags.iter().cloned().collect();
708 tags.push(dup);
709 rumor = stream::build_rumor_secs(kind::CONTROL, owner.public_key(), &rumor.content, tags, 100);
710 assert!(matches!(
711 parse_edition_rumor(&rumor),
712 Err(ControlError::Edition(EditionError::BadField("duplicate authority tag")))
713 ));
714 }
715
716 #[test]
717 fn a_non_canonical_ev_version_is_rejected() {
718 let owner = Keys::generate();
719 for bad in ["007", "+5", "01"] {
722 let tags = vec![
723 Tag::custom("vsk", [vsk::GRANT]),
724 Tag::custom("eid", [crate::simd::hex::bytes_to_hex_32(&[0x55; 32])]),
725 Tag::custom("ev", [bad]),
726 ];
727 let rumor = stream::build_rumor_secs(kind::CONTROL, owner.public_key(), "{\"member\":\"aa\",\"role_ids\":[]}", tags, 100);
728 assert!(
729 matches!(parse_edition_rumor(&rumor), Err(ControlError::Edition(EditionError::BadField("ev")))),
730 "a non-canonical ev {bad:?} is rejected"
731 );
732 }
733 }
734
735 #[test]
736 fn compaction_rewrap_preserves_the_edition_chain_identity() {
737 let owner = Keys::generate();
741 let e0 = group_at(0);
742 let rumor = simple_edition(&owner, 3, Some(&[0x77; 32]));
743 let (wrap, _) = seal_control_edition(&rumor, &e0, &owner, Timestamp::from_secs(10)).unwrap();
744 let (edition, opened) = open_control_edition(&wrap, &e0).unwrap();
745
746 let e1 = group_at(1);
747 let (rewrapped, _) = stream::rewrap_seal(&opened.seal, &e1, Timestamp::from_secs(20)).unwrap();
748 let (re_edition, _) = open_control_edition(&rewrapped, &e1).unwrap();
749
750 assert_eq!(re_edition.self_hash, edition.self_hash);
751 assert_eq!(re_edition.author, edition.author);
752 assert_eq!(re_edition.inner_id, edition.inner_id, "rumor id survives compaction");
753 }
754
755 #[test]
756 fn editions_opened_from_wraps_fold_to_the_head() {
757 let owner = Keys::generate();
758 let group = group_at(0);
759 let entity = [0x55; 32];
760 let content = |v: u64| format!("{{\"v\":{v}}}");
761
762 let mut prev: Option<[u8; 32]> = None;
764 let mut parsed = Vec::new();
765 for v in 1..=3u64 {
766 let rumor = build_edition_rumor(owner.public_key(), vsk::GRANT, &entity, v, prev.as_ref(), &content(v), 100 + v, None);
767 let (wrap, _) = seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(100 + v)).unwrap();
768 let (edition, _) = open_control_edition(&wrap, &group).unwrap();
769 prev = Some(edition.self_hash);
770 parsed.push(edition);
771 }
772
773 let fold_editions: Vec<version::Edition> = parsed.iter().map(|p| p.to_fold_edition()).collect();
774 let folded = version::fold(&fold_editions, 0, None);
775 assert_eq!(fold_editions[folded.head.expect("chain folds")].version, 3);
776 assert!(!folded.gap);
777
778 let partial = [fold_editions[0].clone(), fold_editions[2].clone()];
780 let gapped = version::fold(&partial, 0, None);
781 assert_eq!(partial[gapped.head.expect("genesis edition anchors")].version, 1);
782 assert!(gapped.gap, "a missing middle version is a gap, not a silent skip");
783 }
784
785 #[test]
786 fn genesis_mints_a_verifiable_two_edition_community() {
787 let owner = Keys::generate();
788 let meta = CommunityMetadata {
789 name: "Vector".into(),
790 description: Some("Private messaging, no compromises.".into()),
791 relays: vec!["wss://jskitty.com/nostr".into()],
792 ..Default::default()
793 };
794 let g = genesis(&owner, meta, 1_700_000_000).unwrap();
795
796 assert!(g.identity.verify());
798 assert_eq!(g.identity.owner().unwrap(), owner.public_key());
799
800 let group = split_write_group(&g.control_root, &g.community_root, &g.identity.community_id, Epoch(0));
803 assert_eq!(group.pk(), g.control_pk(), "the plane address is the signer's pk");
804 assert_ne!(group.pk(), control_group_key(&g.community_root, &g.identity.community_id, Epoch(0)).pk(), "never the legacy address");
805 let (meta_ed, _) = open_control_edition(&g.wraps[0], &group).unwrap();
806 let (chan_ed, _) = open_control_edition(&g.wraps[1], &group).unwrap();
807 assert_eq!(meta_ed.author, owner.public_key());
808 assert_eq!(chan_ed.author, owner.public_key());
809 assert_eq!(meta_ed.vsk, vsk::COMMUNITY_METADATA);
810 assert_eq!(chan_ed.vsk, vsk::CHANNEL_METADATA);
811 assert_eq!(meta_ed.entity_id, g.identity.community_id.0);
813 assert_eq!(chan_ed.entity_id, g.general_channel_id.0);
814 for e in [&meta_ed, &chan_ed] {
816 assert_eq!(e.version, 1);
817 assert_eq!(e.prev_hash, None);
818 assert_eq!(e.authority, None);
819 }
820 let general: ChannelMetadata = serde_json::from_str(&chan_ed.content).unwrap();
821 assert_eq!(general.name, "general");
822 assert!(!general.private);
823 }
824
825 #[test]
826 fn a_member_view_reads_the_split_plane_but_cannot_write_and_a_bad_wrap_sig_is_refused() {
827 let owner = Keys::generate();
832 let g = genesis(&owner, CommunityMetadata { name: "Gate".into(), ..Default::default() }, 1_000).unwrap();
833 let mut member = super::super::community::CommunityV2::from_genesis(&g, "Gate", None, vec![], 0);
834 member.control_root = None; let view = ControlPlane::of(&member);
836 assert!(!view.can_write());
837 let err = view.write_group().unwrap_err();
838 assert!(err.contains("staff"), "a readable refusal, not a panic: {err}");
839
840 let (ed, _) = view.open(&g.wraps[0]).unwrap();
842 assert_eq!(ed.author, owner.public_key());
843
844 member.control_root = Some([0x77u8; 32]);
846 assert!(!ControlPlane::of(&member).can_write(), "a non-deriving secret is corrupt state, not a signer");
847
848 let mut json: serde_json::Value = serde_json::from_str(&g.wraps[0].as_json()).unwrap();
852 json["sig"] = serde_json::Value::String("aa".repeat(64));
853 let tampered = Event::from_json(json.to_string()).unwrap();
854 member.control_root = None;
855 assert!(
856 matches!(ControlPlane::of(&member).open(&tampered), Err(ControlError::Stream(StreamError::BadWrapSignature))),
857 "the wrap signature IS the write gate on a restricted stream"
858 );
859 }
860
861 #[test]
862 fn a_forged_identity_fails_the_commitment() {
863 let owner = Keys::generate();
864 let attacker = Keys::generate();
865 let real = CommunityIdentity::mint(&owner.public_key());
866 let forged = CommunityIdentity {
869 community_id: real.community_id,
870 owner_xonly: attacker.public_key().to_bytes(),
871 owner_salt: real.owner_salt,
872 };
873 assert!(!forged.verify());
874 }
875
876 #[test]
877 fn metadata_caps_and_unknown_field_round_trip() {
878 let over_name = CommunityMetadata { name: "x".repeat(MAX_NAME_BYTES + 1), ..Default::default() };
879 assert!(matches!(validate_community_metadata(&over_name), Err(ControlError::OverCap("name"))));
880 let over_desc = CommunityMetadata {
881 name: "ok".into(),
882 description: Some("d".repeat(MAX_DESCRIPTION_BYTES + 1)),
883 ..Default::default()
884 };
885 assert!(matches!(validate_community_metadata(&over_desc), Err(ControlError::OverCap("description"))));
886 let multibyte = ChannelMetadata { name: "€".repeat(22), private: false, ..Default::default() };
888 assert!(matches!(validate_channel_metadata(&multibyte), Err(ControlError::OverCap("name"))));
889
890 let wire = r#"{"name":"Vector","relays":["wss://a"],"icon":{"url":"u","key":"k","nonce":"n","hash":"h","ext":"png"},"custom":{"rules":"Be excellent."},"future_field":{"deep":[1,2]}}"#;
893 let parsed: CommunityMetadata = serde_json::from_str(wire).unwrap();
894 let out = serde_json::to_string(&parsed).unwrap();
895 let reparsed: serde_json::Value = serde_json::from_str(&out).unwrap();
896 assert_eq!(reparsed["future_field"]["deep"][1], 2);
897 assert_eq!(reparsed["icon"]["ext"], "png");
898 assert_eq!(reparsed["custom"]["rules"], "Be excellent.");
899 }
900}