Skip to main content

vector_core/community/v2/
control.rs

1//! The v2 Control Plane (CORD-04 over CORD-01/02).
2//!
3//! Editions keep v1's exact grammar — the `vsk/eid/ev/ep/vac` tags, the frozen
4//! `edition_hash` (`community::version`, label `vector-community/v1/edition`,
5//! which upstream froze verbatim), and the fold rules — but ride the v2 stream
6//! envelope: a kind-3308 UNSIGNED rumor inside a **plaintext seal** (20014) at
7//! `control_pk`. Authorship moves from the rumor's own signature (v1) to the
8//! seal's (v2): [`super::stream::open_wrap`] Schnorr-verifies the seal and pins
9//! `rumor.pubkey == seal.pubkey`, so by the time an edition is parsed here its
10//! author is already proven. The plaintext seal is load-bearing: a compaction
11//! re-wraps the signed seal into a new epoch byte-verbatim, and the signature
12//! (over the rumor string) survives — an encrypted seal could not.
13//!
14//! Two v1↔v2 wire deltas, both deliberate:
15//!   - v2 editions carry NO `["v","1"]` protocol tag (frozen derivations
16//!     partition protocol revisions by address; version tags are the rejected
17//!     mechanism).
18//!   - the owner is proven by the self-certifying `community_id` commitment
19//!     ([`super::derive::verify_community_id`]), not an attestation event —
20//!     vsk 7 is retired.
21
22use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, Timestamp, UnsignedEvent};
23use serde::{Deserialize, Serialize};
24
25use super::super::edition::{AuthorityCitation, EditionError, ParsedEdition, TAG_AUTHORITY_CITATION};
26use super::super::{version, ChannelId, CommunityId, Epoch};
27use super::derive::{control_group_key, verify_community_id, GroupKey};
28use super::stream::{self, OpenedStream, SealForm, StreamError};
29use super::{kind, vsk};
30
31const TAG_SUBKIND: &str = "vsk";
32const TAG_ENTITY: &str = "eid";
33const TAG_EVERSION: &str = "ev";
34const TAG_EPREV: &str = "ep";
35
36/// Protocol-wide UTF-8 byte cap on names (community, channel, role).
37pub const MAX_NAME_BYTES: usize = 64;
38/// UTF-8 byte cap on a community description.
39pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
40
41/// Errors from the control plane layer (envelope errors ride inside).
42#[derive(Debug)]
43pub enum ControlError {
44    Stream(StreamError),
45    Edition(EditionError),
46    /// The rumor isn't a kind-3308 edition.
47    NotAnEdition(u16),
48    /// A control edition arrived in an encrypted seal — CORD-02 §5 requires the
49    /// plaintext form (compaction must preserve signatures), so a strict reader
50    /// drops it rather than folding a chain a re-wrap would later fork.
51    NotPlaintextSealed,
52    /// A name/description exceeds its protocol byte cap.
53    OverCap(&'static str),
54}
55
56impl std::fmt::Display for ControlError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            ControlError::Stream(e) => write!(f, "stream: {e}"),
60            ControlError::Edition(e) => write!(f, "edition: {e:?}"),
61            ControlError::NotAnEdition(k) => write!(f, "rumor kind {k} is not a control edition"),
62            ControlError::NotPlaintextSealed => write!(f, "control edition must ride a plaintext seal"),
63            ControlError::OverCap(what) => write!(f, "{what} exceeds its byte cap"),
64        }
65    }
66}
67
68impl std::error::Error for ControlError {}
69
70impl From<StreamError> for ControlError {
71    fn from(e: StreamError) -> Self {
72        ControlError::Stream(e)
73    }
74}
75
76// ── Identity ─────────────────────────────────────────────────────────────────
77
78/// A v2 community's self-certifying identity triple. The id IS the owner
79/// commitment — carry all three together and verify before trusting any claim.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct CommunityIdentity {
82    pub community_id: CommunityId,
83    pub owner_xonly: [u8; 32],
84    pub owner_salt: [u8; 32],
85}
86
87impl CommunityIdentity {
88    /// Mint a fresh identity for `owner` (a new random salt ⇒ a new community).
89    pub fn mint(owner: &PublicKey) -> CommunityIdentity {
90        let owner_xonly = owner.to_bytes();
91        let owner_salt = super::super::random_32();
92        CommunityIdentity {
93            community_id: super::derive::community_id_of(&owner_xonly, &owner_salt),
94            owner_xonly,
95            owner_salt,
96        }
97    }
98
99    /// True iff the commitment reproduces the id — the ONLY valid owner proof.
100    pub fn verify(&self) -> bool {
101        verify_community_id(&self.community_id, &self.owner_xonly, &self.owner_salt)
102    }
103
104    /// The proven owner as a `PublicKey` (call only after [`Self::verify`]).
105    pub fn owner(&self) -> Result<PublicKey, String> {
106        PublicKey::from_slice(&self.owner_xonly).map_err(|e| e.to_string())
107    }
108}
109
110// ── Edition rumors (build + parse) ───────────────────────────────────────────
111
112/// Build the unsigned kind-3308 edition rumor — v1's grammar minus the protocol
113/// `v` tag. Control editions carry no `ms` tag: they fold by version, not time.
114#[allow(clippy::too_many_arguments)]
115pub fn build_edition_rumor(
116    author: PublicKey,
117    vsk: &str,
118    entity_id: &[u8; 32],
119    version: u64,
120    prev_hash: Option<&[u8; 32]>,
121    content: &str,
122    created_at_secs: u64,
123    authority: Option<&AuthorityCitation>,
124) -> UnsignedEvent {
125    let mut tags = vec![
126        Tag::custom(TAG_SUBKIND, [vsk.to_string()]),
127        Tag::custom(TAG_ENTITY, [crate::simd::hex::bytes_to_hex_32(entity_id)]),
128        Tag::custom(TAG_EVERSION, [version.to_string()]),
129    ];
130    if let Some(p) = prev_hash {
131        tags.push(Tag::custom(TAG_EPREV, [crate::simd::hex::bytes_to_hex_32(p)]));
132    }
133    if let Some(a) = authority {
134        tags.push(a.to_tag());
135    }
136    stream::build_rumor_secs(kind::CONTROL, author, content, tags, created_at_secs)
137}
138
139/// Parse an edition from an ALREADY-VERIFIED rumor (one produced by
140/// [`stream::open_wrap`], which proved the seal signature, the author binding,
141/// and the rumor id). No signature lives on the rumor itself — never feed this
142/// a rumor that didn't come through the stream verifier.
143pub fn parse_edition_rumor(rumor: &UnsignedEvent) -> Result<ParsedEdition, ControlError> {
144    if rumor.kind.as_u16() != kind::CONTROL {
145        return Err(ControlError::NotAnEdition(rumor.kind.as_u16()));
146    }
147    // Duplicate machinery tags make signed-bytes → canonical-fields ambiguous
148    // (two clients could pick different duplicates and fork on self_hash).
149    for name in [TAG_SUBKIND, TAG_ENTITY, TAG_EVERSION, TAG_EPREV, TAG_AUTHORITY_CITATION] {
150        let count = rumor
151            .tags
152            .iter()
153            .filter(|t| t.as_slice().first().map(|s| s.as_str() == name).unwrap_or(false))
154            .count();
155        if count > 1 {
156            return Err(ControlError::Edition(EditionError::BadField("duplicate authority tag")));
157        }
158    }
159    let get = |name: &str| -> Option<String> {
160        rumor.tags.iter().find_map(|t| {
161            let s = t.as_slice();
162            (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
163        })
164    };
165    let decode_hash = |hex: &str, field: &'static str| -> Result<[u8; 32], ControlError> {
166        if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
167            return Err(ControlError::Edition(EditionError::BadField(field)));
168        }
169        Ok(crate::simd::hex::hex_to_bytes_32(hex))
170    };
171    let vsk = get(TAG_SUBKIND).ok_or(ControlError::Edition(EditionError::MissingField("vsk")))?;
172    let entity_id = decode_hash(&get(TAG_ENTITY).ok_or(ControlError::Edition(EditionError::MissingField("eid")))?, "eid")?;
173    // Decimal, no leading zeros (CORD-01 Encoding): `u64::from_str` accepts a leading
174    // `+`, and "007"/"7" are distinct signed rumors that fold to the same version — a
175    // same-version convergence fork a strict peer (which drops the padded form)
176    // resolves differently. Reject any non-canonical decimal, matching the `ms` rule.
177    let ev_raw = get(TAG_EVERSION).ok_or(ControlError::Edition(EditionError::MissingField("ev")))?;
178    if !crate::community::edition::is_tag_decimal(&ev_raw) {
179        return Err(ControlError::Edition(EditionError::BadField("ev")));
180    }
181    let version: u64 = ev_raw.parse().map_err(|_| ControlError::Edition(EditionError::BadField("ev")))?;
182    let prev_hash = match get(TAG_EPREV) {
183        Some(h) => Some(decode_hash(&h, "ep")?),
184        None => None,
185    };
186    let self_hash = version::edition_hash(&entity_id, version, prev_hash.as_ref(), rumor.content.as_bytes());
187    Ok(ParsedEdition {
188        author: rumor.pubkey,
189        vsk,
190        entity_id,
191        version,
192        prev_hash,
193        content: rumor.content.clone(),
194        self_hash,
195        created_at: rumor.created_at.as_secs(),
196        inner_id: rumor.id.expect("verified rumors carry their id").to_bytes(),
197        authority: AuthorityCitation::from_tags(&rumor.tags),
198    })
199}
200
201// ── Seal / open over the stream ──────────────────────────────────────────────
202
203/// Seal a signed-by-`author_keys` edition rumor into a control-plane wrap.
204/// Local-keys convenience; bunker accounts use [`stream::seal_content`] +
205/// their remote signer + [`stream::wrap_seal`] for identical wire output.
206pub fn seal_control_edition(
207    rumor: &UnsignedEvent,
208    group: &GroupKey,
209    author_keys: &Keys,
210    wrap_at: Timestamp,
211) -> Result<(Event, Keys), ControlError> {
212    let seal = stream::build_seal(rumor, SealForm::Plaintext, group, author_keys)?;
213    Ok(stream::wrap_seal(&seal, group, stream::KIND_WRAP, wrap_at)?)
214}
215
216/// Signer-driven twin of [`seal_control_edition`] for bunker / NIP-55 accounts:
217/// the plaintext seal signs through a [`VectorSigner`]. `author` is the identity
218/// the signer signs as (must equal `my_public_key()`). Wire-identical output.
219pub async fn seal_control_edition_signed<S: crate::signer::VectorSigner + ?Sized>(
220    signer: &S,
221    author: PublicKey,
222    rumor: &UnsignedEvent,
223    group: &GroupKey,
224    wrap_at: Timestamp,
225) -> Result<(Event, Keys), ControlError> {
226    Ok(stream::seal_and_wrap_signed(signer, author, rumor, SealForm::Plaintext, group, stream::KIND_WRAP, wrap_at, &[]).await?)
227}
228
229/// Open a control-plane wrap into a verified, parsed edition. Strict on both
230/// gates: the rumor must be kind 3308, and the seal must be the plaintext form.
231pub fn open_control_edition(wrap: &Event, group: &GroupKey) -> Result<(ParsedEdition, OpenedStream), ControlError> {
232    let opened = stream::open_wrap(wrap, group)?;
233    if opened.seal_form != SealForm::Plaintext {
234        return Err(ControlError::NotPlaintextSealed);
235    }
236    let edition = parse_edition_rumor(&opened.rumor)?;
237    Ok((edition, opened))
238}
239
240// ── Entity payloads (vsk 0 / vsk 2) ──────────────────────────────────────────
241
242/// An encrypted-blob image pointer (icon/banner): the media server sees only an
243/// opaque blob; members fetch, decrypt with `key`/`nonce`, and verify `hash`.
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct ImageRef {
246    pub url: String,
247    pub key: String,
248    pub nonce: String,
249    pub hash: String,
250    /// Unknown fields round-trip (e.g. Vector's `ext`) — editors MUST preserve
251    /// what they don't understand (CORD-02 §6).
252    #[serde(flatten)]
253    pub extra: serde_json::Map<String, serde_json::Value>,
254}
255
256impl ImageRef {
257    /// Bridge to the v1 image-ref shape so the shared download/decrypt/cache
258    /// plumbing serves both protocols. `ext` rides the flattened extras; fall
259    /// back to the URL's extension, else png.
260    pub fn to_community_image(&self) -> crate::community::CommunityImage {
261        let ext = self
262            .extra
263            .get("ext")
264            .and_then(|v| v.as_str())
265            .filter(|s| !s.is_empty())
266            .map(str::to_string)
267            .or_else(|| {
268                std::path::Path::new(&self.url)
269                    .extension()
270                    .and_then(|e| e.to_str())
271                    .map(|s| s.to_lowercase())
272            })
273            .unwrap_or_else(|| "png".to_string());
274        crate::community::CommunityImage {
275            url: self.url.clone(),
276            key: self.key.clone(),
277            nonce: self.nonce.clone(),
278            hash: self.hash.clone(),
279            ext,
280        }
281    }
282}
283
284/// Community metadata — the vsk-0 entity content (CORD-02 §6). `eid` = the
285/// community_id itself; gated by `MANAGE_METADATA`.
286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
287pub struct CommunityMetadata {
288    pub name: String,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub description: Option<String>,
291    #[serde(default, skip_serializing_if = "Vec::is_empty")]
292    pub relays: Vec<String>,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub icon: Option<ImageRef>,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub banner: Option<ImageRef>,
297    /// Client-extensible opaque object; folds atomically with the entity.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub custom: Option<serde_json::Map<String, serde_json::Value>>,
300    /// Reserved-for-protocol unknown top-level fields, round-tripped verbatim.
301    #[serde(flatten)]
302    pub extra: serde_json::Map<String, serde_json::Value>,
303}
304
305/// Channel metadata — the vsk-2 entity content (CORD-03 §2). `eid` = the
306/// channel_id; gated by `MANAGE_CHANNELS`. Absent flags mean false; deletion is
307/// terminal.
308#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
309pub struct ChannelMetadata {
310    pub name: String,
311    pub private: bool,
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub voice: Option<bool>,
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub deleted: Option<bool>,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub custom: Option<serde_json::Map<String, serde_json::Value>>,
318    #[serde(flatten)]
319    pub extra: serde_json::Map<String, serde_json::Value>,
320}
321
322/// Enforce the protocol byte caps before an edition is built (a strict reader
323/// may drop over-cap state; never publish what peers would refuse).
324pub fn validate_community_metadata(meta: &CommunityMetadata) -> Result<(), ControlError> {
325    if meta.name.len() > MAX_NAME_BYTES {
326        return Err(ControlError::OverCap("name"));
327    }
328    if meta.description.as_ref().is_some_and(|d| d.len() > MAX_DESCRIPTION_BYTES) {
329        return Err(ControlError::OverCap("description"));
330    }
331    Ok(())
332}
333
334pub fn validate_channel_metadata(meta: &ChannelMetadata) -> Result<(), ControlError> {
335    if meta.name.len() > MAX_NAME_BYTES {
336        return Err(ControlError::OverCap("name"));
337    }
338    Ok(())
339}
340
341// ── Genesis (CORD-02 §1) ─────────────────────────────────────────────────────
342
343/// The two wraps of a community genesis — exactly two owner-signed editions:
344/// the community metadata (vsk 0) and one public `#general` channel (vsk 2).
345/// Nothing more — no default roles, no scaffolding.
346pub struct Genesis {
347    pub identity: CommunityIdentity,
348    /// The community_root minted for epoch 0.
349    pub community_root: [u8; 32],
350    pub general_channel_id: ChannelId,
351    /// `[metadata wrap, #general wrap]`, both sealed at the epoch-0 control_pk.
352    pub wraps: [Event; 2],
353}
354
355/// Mint a v2 community: fresh identity (salt-committed to the owner), fresh
356/// community_root, and the two genesis editions sealed at the epoch-0 control
357/// plane. The caller persists the secrets and publishes the wraps.
358pub fn genesis(owner_keys: &Keys, mut metadata: CommunityMetadata, at_secs: u64) -> Result<Genesis, ControlError> {
359    validate_community_metadata(&metadata)?;
360    // Relays ride the metadata entity so they can evolve by edit; cap on write.
361    metadata.relays.truncate(super::super::MAX_COMMUNITY_RELAYS);
362
363    let identity = CommunityIdentity::mint(&owner_keys.public_key());
364    let community_root = super::super::random_32();
365    let general_channel_id = ChannelId(super::super::random_32());
366    let group = control_group_key(&community_root, &identity.community_id, Epoch(0));
367
368    let meta_json = serde_json::to_string(&metadata).map_err(|e| ControlError::Stream(StreamError::Parse(e.to_string())))?;
369    let meta_rumor = build_edition_rumor(
370        owner_keys.public_key(),
371        vsk::COMMUNITY_METADATA,
372        &identity.community_id.0,
373        1,
374        None,
375        &meta_json,
376        at_secs,
377        None,
378    );
379
380    let general = ChannelMetadata { name: "general".into(), private: false, ..Default::default() };
381    let general_json = serde_json::to_string(&general).map_err(|e| ControlError::Stream(StreamError::Parse(e.to_string())))?;
382    let general_rumor = build_edition_rumor(
383        owner_keys.public_key(),
384        vsk::CHANNEL_METADATA,
385        &general_channel_id.0,
386        1,
387        None,
388        &general_json,
389        at_secs,
390        None,
391    );
392
393    let (meta_wrap, _) = seal_control_edition(&meta_rumor, &group, owner_keys, Timestamp::from_secs(at_secs))?;
394    let (general_wrap, _) = seal_control_edition(&general_rumor, &group, owner_keys, Timestamp::from_secs(at_secs))?;
395
396    Ok(Genesis {
397        identity,
398        community_root,
399        general_channel_id,
400        wraps: [meta_wrap, general_wrap],
401    })
402}
403
404/// Signer-driven twin of [`genesis`] for bunker / NIP-55 accounts: mints from the
405/// owner's public key and seals the two genesis editions through a [`VectorSigner`].
406/// `owner_pk` must equal `my_public_key()` (the identity the signer signs as).
407pub async fn genesis_signed<S: crate::signer::VectorSigner + ?Sized>(
408    owner_pk: PublicKey,
409    signer: &S,
410    metadata: CommunityMetadata,
411    at_secs: u64,
412) -> Result<Genesis, ControlError> {
413    genesis_signed_with_primary(owner_pk, signer, metadata, at_secs, None).await
414}
415
416/// [`genesis_signed`] with an OPTIONAL explicit primary-channel id + name — the
417/// migration-only path (§migration) so the v2 twin's #general reuses the v1 primary
418/// channel id and history stitches through the flip. `None` mints a fresh id (the
419/// ordinary create path).
420pub async fn genesis_signed_with_primary<S: crate::signer::VectorSigner + ?Sized>(
421    owner_pk: PublicKey,
422    signer: &S,
423    mut metadata: CommunityMetadata,
424    at_secs: u64,
425    primary: Option<(ChannelId, String)>,
426) -> Result<Genesis, ControlError> {
427    validate_community_metadata(&metadata)?;
428    metadata.relays.truncate(super::super::MAX_COMMUNITY_RELAYS);
429
430    let identity = CommunityIdentity::mint(&owner_pk);
431    let community_root = super::super::random_32();
432    let (general_channel_id, general_name) = match primary {
433        Some((id, name)) => (id, name),
434        None => (ChannelId(super::super::random_32()), "general".to_string()),
435    };
436    let group = control_group_key(&community_root, &identity.community_id, Epoch(0));
437
438    let meta_json = serde_json::to_string(&metadata).map_err(|e| ControlError::Stream(StreamError::Parse(e.to_string())))?;
439    let meta_rumor = build_edition_rumor(owner_pk, vsk::COMMUNITY_METADATA, &identity.community_id.0, 1, None, &meta_json, at_secs, None);
440
441    let general = ChannelMetadata { name: general_name, private: false, ..Default::default() };
442    let general_json = serde_json::to_string(&general).map_err(|e| ControlError::Stream(StreamError::Parse(e.to_string())))?;
443    let general_rumor = build_edition_rumor(owner_pk, vsk::CHANNEL_METADATA, &general_channel_id.0, 1, None, &general_json, at_secs, None);
444
445    let (meta_wrap, _) = seal_control_edition_signed(signer, owner_pk, &meta_rumor, &group, Timestamp::from_secs(at_secs)).await?;
446    let (general_wrap, _) = seal_control_edition_signed(signer, owner_pk, &general_rumor, &group, Timestamp::from_secs(at_secs)).await?;
447
448    Ok(Genesis {
449        identity,
450        community_root,
451        general_channel_id,
452        wraps: [meta_wrap, general_wrap],
453    })
454}
455
456#[cfg(test)]
457mod tests {
458    use nostr_sdk::prelude::FinalizeEvent;
459    use super::super::super::edition::build_edition_inner;
460    use super::*;
461
462    fn cid() -> CommunityId {
463        CommunityId([0x33; 32])
464    }
465
466    fn group_at(epoch: u64) -> GroupKey {
467        control_group_key(&[0x44; 32], &cid(), Epoch(epoch))
468    }
469
470    fn simple_edition(author: &Keys, version: u64, prev: Option<&[u8; 32]>) -> UnsignedEvent {
471        build_edition_rumor(
472            author.public_key(),
473            vsk::GRANT,
474            &[0x55; 32],
475            version,
476            prev,
477            "{\"member\":\"aa\",\"role_ids\":[]}",
478            1_700_000_000,
479            None,
480        )
481    }
482
483    #[test]
484    fn edition_round_trips_through_the_control_plane() {
485        let owner = Keys::generate();
486        let group = group_at(0);
487        let cite = AuthorityCitation { entity_id: [0xab; 32], version: 7, edition_hash: [0xcd; 32] };
488        let rumor = build_edition_rumor(
489            owner.public_key(),
490            vsk::GRANT,
491            &[0x55; 32],
492            2,
493            Some(&[0x66; 32]),
494            "{\"member\":\"aa\",\"role_ids\":[]}",
495            1_700_000_000,
496            Some(&cite),
497        );
498        let (wrap, _) = seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(1_700_000_001)).unwrap();
499
500        let (edition, opened) = open_control_edition(&wrap, &group).unwrap();
501        assert_eq!(edition.author, owner.public_key());
502        assert_eq!(edition.vsk, vsk::GRANT);
503        assert_eq!(edition.entity_id, [0x55; 32]);
504        assert_eq!(edition.version, 2);
505        assert_eq!(edition.prev_hash, Some([0x66; 32]));
506        assert_eq!(edition.authority.as_ref(), Some(&cite));
507        assert_eq!(opened.seal_form, SealForm::Plaintext);
508        // self_hash matches the canonical recomputation.
509        assert_eq!(
510            edition.self_hash,
511            version::edition_hash(&[0x55; 32], 2, Some(&[0x66; 32]), rumor.content.as_bytes())
512        );
513    }
514
515    #[test]
516    fn v2_edition_tags_carry_no_protocol_version_tag() {
517        // FROZEN: the v2 tag set is exactly vsk/eid/ev(+ep/vac) — a `v` tag is
518        // the rejected versioning mechanism (address partitioning does the job).
519        let owner = Keys::generate();
520        let rumor = simple_edition(&owner, 1, None);
521        assert!(
522            !rumor.tags.iter().any(|t| t.as_slice().first().map(|s| s == "v").unwrap_or(false)),
523            "v2 editions must not carry a protocol version tag"
524        );
525        // And no ms tag — editions fold by version, not time.
526        assert!(!rumor.tags.iter().any(|t| t.as_slice().first().map(|s| s == "ms").unwrap_or(false)));
527    }
528
529    #[test]
530    fn edition_hash_is_identical_across_protocols() {
531        // The edition hash is the ONE construction both protocols share (upstream
532        // froze v1's byte layout, label included). The same logical edition must
533        // hash identically whether built as a v1 signed inner or a v2 rumor —
534        // this is what makes the fold engine shareable.
535        let author = Keys::generate();
536        let entity = [0x55; 32];
537        let content = "{\"member\":\"aa\",\"role_ids\":[]}";
538        let v1_inner = build_edition_inner(author.public_key(), "3", &entity, 2, Some(&[0x66; 32]), content, 100, None)
539            .finalize(&author)
540            .unwrap();
541        let v1_parsed = super::super::super::edition::parse_edition_inner(&v1_inner).unwrap();
542
543        let v2_rumor = build_edition_rumor(author.public_key(), "3", &entity, 2, Some(&[0x66; 32]), content, 100, None);
544        let v2_parsed = parse_edition_rumor(&v2_rumor).unwrap();
545
546        assert_eq!(v1_parsed.self_hash, v2_parsed.self_hash);
547    }
548
549    #[test]
550    fn encrypted_seal_control_edition_is_rejected() {
551        let owner = Keys::generate();
552        let group = group_at(0);
553        let rumor = simple_edition(&owner, 1, None);
554        let seal = stream::build_seal(&rumor, SealForm::Encrypted, &group, &owner).unwrap();
555        let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
556        assert!(matches!(open_control_edition(&wrap, &group), Err(ControlError::NotPlaintextSealed)));
557    }
558
559    #[test]
560    fn non_edition_rumor_is_rejected() {
561        let owner = Keys::generate();
562        let group = group_at(0);
563        let rumor = stream::build_rumor_secs(super::kind::MESSAGE, owner.public_key(), "hi", vec![], 100);
564        let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group, &owner).unwrap();
565        let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
566        assert!(matches!(open_control_edition(&wrap, &group), Err(ControlError::NotAnEdition(k)) if k == super::kind::MESSAGE));
567    }
568
569    #[test]
570    fn duplicate_machinery_tags_are_rejected() {
571        let owner = Keys::generate();
572        let mut rumor = simple_edition(&owner, 1, None);
573        let dup = Tag::custom("eid", [crate::simd::hex::bytes_to_hex_32(&[0x55; 32])]);
574        let mut tags: Vec<Tag> = rumor.tags.iter().cloned().collect();
575        tags.push(dup);
576        rumor = stream::build_rumor_secs(kind::CONTROL, owner.public_key(), &rumor.content, tags, 100);
577        assert!(matches!(
578            parse_edition_rumor(&rumor),
579            Err(ControlError::Edition(EditionError::BadField("duplicate authority tag")))
580        ));
581    }
582
583    #[test]
584    fn a_non_canonical_ev_version_is_rejected() {
585        let owner = Keys::generate();
586        // A leading-zero (or `+`-prefixed) version is a distinct rumor that folds to
587        // the same numeric version — a same-version fork a strict peer drops.
588        for bad in ["007", "+5", "01"] {
589            let tags = vec![
590                Tag::custom("vsk", [vsk::GRANT]),
591                Tag::custom("eid", [crate::simd::hex::bytes_to_hex_32(&[0x55; 32])]),
592                Tag::custom("ev", [bad]),
593            ];
594            let rumor = stream::build_rumor_secs(kind::CONTROL, owner.public_key(), "{\"member\":\"aa\",\"role_ids\":[]}", tags, 100);
595            assert!(
596                matches!(parse_edition_rumor(&rumor), Err(ControlError::Edition(EditionError::BadField("ev")))),
597                "a non-canonical ev {bad:?} is rejected"
598            );
599        }
600    }
601
602    #[test]
603    fn compaction_rewrap_preserves_the_edition_chain_identity() {
604        // The whole reason control seals are plaintext: carry a signed head into
605        // a new epoch and its self_hash + authorship must be untouched, so a
606        // fresh joiner folds the same chain the old epoch held.
607        let owner = Keys::generate();
608        let e0 = group_at(0);
609        let rumor = simple_edition(&owner, 3, Some(&[0x77; 32]));
610        let (wrap, _) = seal_control_edition(&rumor, &e0, &owner, Timestamp::from_secs(10)).unwrap();
611        let (edition, opened) = open_control_edition(&wrap, &e0).unwrap();
612
613        let e1 = group_at(1);
614        let (rewrapped, _) = stream::rewrap_seal(&opened.seal, &e1, Timestamp::from_secs(20)).unwrap();
615        let (re_edition, _) = open_control_edition(&rewrapped, &e1).unwrap();
616
617        assert_eq!(re_edition.self_hash, edition.self_hash);
618        assert_eq!(re_edition.author, edition.author);
619        assert_eq!(re_edition.inner_id, edition.inner_id, "rumor id survives compaction");
620    }
621
622    #[test]
623    fn editions_opened_from_wraps_fold_to_the_head() {
624        let owner = Keys::generate();
625        let group = group_at(0);
626        let entity = [0x55; 32];
627        let content = |v: u64| format!("{{\"v\":{v}}}");
628
629        // Build a 3-link chain v1 → v2 → v3.
630        let mut prev: Option<[u8; 32]> = None;
631        let mut parsed = Vec::new();
632        for v in 1..=3u64 {
633            let rumor = build_edition_rumor(owner.public_key(), vsk::GRANT, &entity, v, prev.as_ref(), &content(v), 100 + v, None);
634            let (wrap, _) = seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(100 + v)).unwrap();
635            let (edition, _) = open_control_edition(&wrap, &group).unwrap();
636            prev = Some(edition.self_hash);
637            parsed.push(edition);
638        }
639
640        let fold_editions: Vec<version::Edition> = parsed.iter().map(|p| p.to_fold_edition()).collect();
641        let folded = version::fold(&fold_editions, 0, None);
642        assert_eq!(fold_editions[folded.head.expect("chain folds")].version, 3);
643        assert!(!folded.gap);
644
645        // Withhold the middle link: the chain gaps at v1 (fail-closed signal).
646        let partial = [fold_editions[0].clone(), fold_editions[2].clone()];
647        let gapped = version::fold(&partial, 0, None);
648        assert_eq!(partial[gapped.head.expect("genesis edition anchors")].version, 1);
649        assert!(gapped.gap, "a missing middle version is a gap, not a silent skip");
650    }
651
652    #[test]
653    fn genesis_mints_a_verifiable_two_edition_community() {
654        let owner = Keys::generate();
655        let meta = CommunityMetadata {
656            name: "Vector".into(),
657            description: Some("Private messaging, no compromises.".into()),
658            relays: vec!["wss://jskitty.com/nostr".into()],
659            ..Default::default()
660        };
661        let g = genesis(&owner, meta, 1_700_000_000).unwrap();
662
663        // The identity self-certifies and names the owner.
664        assert!(g.identity.verify());
665        assert_eq!(g.identity.owner().unwrap(), owner.public_key());
666
667        // Exactly two editions, both owner-signed, both openable at epoch 0.
668        let group = control_group_key(&g.community_root, &g.identity.community_id, Epoch(0));
669        let (meta_ed, _) = open_control_edition(&g.wraps[0], &group).unwrap();
670        let (chan_ed, _) = open_control_edition(&g.wraps[1], &group).unwrap();
671        assert_eq!(meta_ed.author, owner.public_key());
672        assert_eq!(chan_ed.author, owner.public_key());
673        assert_eq!(meta_ed.vsk, vsk::COMMUNITY_METADATA);
674        assert_eq!(chan_ed.vsk, vsk::CHANNEL_METADATA);
675        // Metadata's coordinate IS the community id; the channel's its channel id.
676        assert_eq!(meta_ed.entity_id, g.identity.community_id.0);
677        assert_eq!(chan_ed.entity_id, g.general_channel_id.0);
678        // Both are genesis editions: version 1, no prev, no citation (owner is supreme).
679        for e in [&meta_ed, &chan_ed] {
680            assert_eq!(e.version, 1);
681            assert_eq!(e.prev_hash, None);
682            assert_eq!(e.authority, None);
683        }
684        let general: ChannelMetadata = serde_json::from_str(&chan_ed.content).unwrap();
685        assert_eq!(general.name, "general");
686        assert!(!general.private);
687    }
688
689    #[test]
690    fn a_forged_identity_fails_the_commitment() {
691        let owner = Keys::generate();
692        let attacker = Keys::generate();
693        let real = CommunityIdentity::mint(&owner.public_key());
694        // An attacker claiming the real community id with their own key + any salt
695        // needs a second preimage — verify() must fail.
696        let forged = CommunityIdentity {
697            community_id: real.community_id,
698            owner_xonly: attacker.public_key().to_bytes(),
699            owner_salt: real.owner_salt,
700        };
701        assert!(!forged.verify());
702    }
703
704    #[test]
705    fn metadata_caps_and_unknown_field_round_trip() {
706        let over_name = CommunityMetadata { name: "x".repeat(MAX_NAME_BYTES + 1), ..Default::default() };
707        assert!(matches!(validate_community_metadata(&over_name), Err(ControlError::OverCap("name"))));
708        let over_desc = CommunityMetadata {
709            name: "ok".into(),
710            description: Some("d".repeat(MAX_DESCRIPTION_BYTES + 1)),
711            ..Default::default()
712        };
713        assert!(matches!(validate_community_metadata(&over_desc), Err(ControlError::OverCap("description"))));
714        // The cap is BYTES, not chars: 22 three-byte chars = 66 bytes > 64.
715        let multibyte = ChannelMetadata { name: "€".repeat(22), private: false, ..Default::default() };
716        assert!(matches!(validate_channel_metadata(&multibyte), Err(ControlError::OverCap("name"))));
717
718        // Round-trip discipline: unknown top-level fields, unknown icon fields,
719        // and the custom object all survive a parse → serialize cycle.
720        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]}}"#;
721        let parsed: CommunityMetadata = serde_json::from_str(wire).unwrap();
722        let out = serde_json::to_string(&parsed).unwrap();
723        let reparsed: serde_json::Value = serde_json::from_str(&out).unwrap();
724        assert_eq!(reparsed["future_field"]["deep"][1], 2);
725        assert_eq!(reparsed["icon"]["ext"], "png");
726        assert_eq!(reparsed["custom"]["rules"], "Be excellent.");
727    }
728}