Skip to main content

vector_core/community/
edition.rs

1//! Real-npub authority editions — the keyless model's authorship + version carrier.
2//!
3//! An authority change (a Grant, RoleMetadata, RoleOrder, Banlist, ...) is an **inner event signed
4//! by the ACTOR's own npub**, carrying the entity id, a per-entity
5//! `version`, and the previous edition's hash (`prev_hash`, see [`super::version`]). That inner event
6//! lives inside the channel/server-root encryption (the outer wrapper is the usual ephemeral signer,
7//! [`super::envelope`]), so authorship is **member-verifiable**: the inner Schnorr signature *is* the
8//! proof of who acted, and members check that npub against the roster.
9//!
10//! This module is the wire encoding of one edition (build + verify + parse). It does NOT decide
11//! authorization — the signature proves WHO acted; the roster (§roles) decides WHETHER they were
12//! allowed, and [`super::version::fold`] decides which edition is current.
13
14use crate::event_ext::FinalizeUnsignedWithId;
15use super::version;
16use crate::stored_event::event_kind;
17use nostr_sdk::prelude::*;
18
19const TAG_SUBKIND: &str = "vsk";
20const TAG_ENTITY: &str = "eid";
21const TAG_EVERSION: &str = "ev";
22const TAG_EPREV: &str = "ep";
23const TAG_VERSION: &str = "v";
24const PROTOCOL_VERSION: &str = "1";
25/// Authority citation tag: `["vac", <authorizing-entity hex>, <version>, <edition-hash hex>]`.
26/// The "pinned proof" — the grant edition the actor claims their authority under. In the MVP it is a
27/// COMPLETENESS floor: a verifier confirms it has synced that exact grant to ≥ the cited version (an
28/// un-forked, complete view) before acting, and resolves the actor's actual rank against its current
29/// (refuse-downgrade-protected) roster — so a since-demoted actor is dropped there. The full 
30/// "resolve rank AT the cited version" (block-until-synced re-fetch + a roster-wide snapshot version) is
31/// the deferred refinement; today it pins the actor's own grant, not a whole-roster moment. Absent when
32/// the OWNER acts (supreme, no grant to cite).
33pub const TAG_AUTHORITY_CITATION: &str = "vac";
34
35/// CORD-01 §5: a tag number rides as "its decimal form with no leading zeros".
36/// So `"4"` and `"0"` are the shape; `"04"`, `"+4"` and `""` are not.
37/// `u64::from_str` accepts a leading `+` and any number of leading zeros, and a
38/// peer that doesn't would drop an event we honored — a divergence neither side
39/// can see, because a declined parse is never logged.
40pub fn is_tag_decimal(raw: &str) -> bool {
41    !raw.is_empty()
42        && raw.bytes().all(|b| b.is_ascii_digit())
43        && !(raw.len() > 1 && raw.starts_with('0'))
44}
45
46/// The pinned authority an actor claims for an action (mechanism a). Points at the actor's own
47/// authorizing edition (their Grant — or a RoleMetadata for a role-position claim) by stable
48/// coordinate + the exact version/hash, so the verifier resolves authority against that frozen point,
49/// not its own possibly-lagging-or-ahead live roster.
50#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51pub struct AuthorityCitation {
52    /// The authorizing edition's entity id (e.g. `grant_locator(community_id, actor)`).
53    pub entity_id: [u8; 32],
54    /// The version of that edition the actor claims authority under.
55    pub version: u64,
56    /// That edition's [`version::edition_hash`] — pins the exact content, not just the version number.
57    pub edition_hash: [u8; 32],
58}
59
60impl AuthorityCitation {
61    /// The signed `vac` tag carrying this citation.
62    pub fn to_tag(&self) -> Tag {
63        Tag::custom(
64            TAG_AUTHORITY_CITATION,
65            [
66                crate::simd::hex::bytes_to_hex_32(&self.entity_id),
67                self.version.to_string(),
68                crate::simd::hex::bytes_to_hex_32(&self.edition_hash),
69            ],
70        )
71    }
72
73    /// Extract the citation from an event's tags, or `None` if absent. A malformed `vac` (bad hex /
74    /// unparseable version) returns `None` — the verifier then treats the action as uncited (owner-only
75    /// or rejected), never trusting a corrupt citation.
76    pub fn from_tags(tags: &Tags) -> Option<AuthorityCitation> {
77        let s = tags.iter().find_map(|t| {
78            let s = t.as_slice();
79            (s.len() >= 4 && s[0] == TAG_AUTHORITY_CITATION).then(|| (s[1].clone(), s[2].clone(), s[3].clone()))
80        })?;
81        let valid_hex = |h: &str| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit());
82        if !valid_hex(&s.0) || !valid_hex(&s.2) {
83            return None;
84        }
85        // Digit-only BEFORE the parse: CORD-01 §5 says a tag number rides as its
86        // decimal form, and `u64::from_str` accepts a leading `+` ("+5") that a
87        // digit-checking peer rejects — so the two would disagree on whether a
88        // citation exists at all, one honoring the action and the other parking
89        // it. Same guard `resolve_ms_strict` already applies to `ms`.
90        if !is_tag_decimal(&s.1) {
91            return None;
92        }
93        Some(AuthorityCitation {
94            entity_id: crate::simd::hex::hex_to_bytes_32(&s.0),
95            version: s.1.parse().ok()?,
96            edition_hash: crate::simd::hex::hex_to_bytes_32(&s.2),
97        })
98    }
99}
100
101/// Build the unsigned inner edition event. Sign it with the ACTOR's real identity keys — that
102/// signature is the authorship proof. `entity_id` is the entity's 32-byte id, `prev_hash` is the
103/// previous edition's [`version::edition_hash`] (`None` for the first edition), `content` is the
104/// entity payload JSON, and `created_at_secs` is the authored time (the version-fold tiebreak).
105pub fn build_edition_inner(
106    author: PublicKey,
107    vsk: &str,
108    entity_id: &[u8; 32],
109    version: u64,
110    prev_hash: Option<&[u8; 32]>,
111    content: &str,
112    created_at_secs: u64,
113    authority: Option<&AuthorityCitation>,
114) -> UnsignedEvent {
115    let mut tags = vec![
116        Tag::custom(TAG_SUBKIND, [vsk.to_string()]),
117        Tag::custom(TAG_ENTITY, [crate::simd::hex::bytes_to_hex_32(entity_id)]),
118        Tag::custom(TAG_EVERSION, [version.to_string()]),
119        Tag::custom(TAG_VERSION, [PROTOCOL_VERSION.to_string()]),
120    ];
121    if let Some(p) = prev_hash {
122        tags.push(Tag::custom(TAG_EPREV, [crate::simd::hex::bytes_to_hex_32(p)]));
123    }
124    // The pinned authority proof: absent when the OWNER signs (supreme), present for a delegated
125    // admin so verifiers resolve their rank at the cited grant version. Outside the version-chain
126    // self_hash (it's per-action metadata, not chain identity), but covered by the inner signature.
127    if let Some(a) = authority {
128        tags.push(a.to_tag());
129    }
130    EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_CONTROL), content)
131        .tags(tags)
132        .custom_created_at(Timestamp::from_secs(created_at_secs))
133        .finalize_unsigned_with_id(author)
134}
135
136/// A signature-verified, parsed edition.
137#[derive(Clone, Debug)]
138pub struct ParsedEdition {
139    /// The real npub that signed (and is thus accountable for) this edition.
140    pub author: PublicKey,
141    pub vsk: String,
142    pub entity_id: [u8; 32],
143    pub version: u64,
144    pub prev_hash: Option<[u8; 32]>,
145    pub content: String,
146    /// [`version::edition_hash`] of this edition — what the next edition's `prev_hash` must cite.
147    pub self_hash: [u8; 32],
148    pub created_at: u64,
149    pub inner_id: [u8; 32],
150    /// The pinned authority proof, if the actor cited one. `None` when the OWNER signs (supreme)
151    /// or a non-authority edition carries no citation. Verified separately against the roster (#3c).
152    pub authority: Option<AuthorityCitation>,
153}
154
155#[derive(Debug, PartialEq, Eq)]
156pub enum EditionError {
157    BadSignature,
158    MissingField(&'static str),
159    BadField(&'static str),
160}
161
162fn decode_hash(hex: &str, field: &'static str) -> Result<[u8; 32], EditionError> {
163    if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
164        return Err(EditionError::BadField(field));
165    }
166    Ok(crate::simd::hex::hex_to_bytes_32(hex))
167}
168
169/// Verify + parse an inner edition event. Checks the inner Schnorr signature (the real-npub
170/// authorship proof) and extracts the edition fields, computing `self_hash` over the canonical
171/// edition bytes. Does NOT check roster authorization — that is the caller's separate step.
172pub fn parse_edition_inner(inner: &Event) -> Result<ParsedEdition, EditionError> {
173    inner.verify().map_err(|_| EditionError::BadSignature)?;
174    // Reject duplicate authority tags: the signature covers all of them, but if two clients picked a
175    // different duplicate they would compute a different `self_hash` for the same signed event and
176    // diverge on the chain. The map signed-event → canonical bytes must be total and unambiguous.
177    for name in [TAG_SUBKIND, TAG_ENTITY, TAG_EVERSION, TAG_EPREV, TAG_AUTHORITY_CITATION] {
178        let count = inner
179            .tags
180            .iter()
181            .filter(|t| t.as_slice().first().map(|s| s.as_str() == name).unwrap_or(false))
182            .count();
183        if count > 1 {
184            return Err(EditionError::BadField("duplicate authority tag"));
185        }
186    }
187    let get = |name: &str| -> Option<String> {
188        inner.tags.iter().find_map(|t| {
189            let s = t.as_slice();
190            (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
191        })
192    };
193    let vsk = get(TAG_SUBKIND).ok_or(EditionError::MissingField("vsk"))?;
194    let entity_id = decode_hash(&get(TAG_ENTITY).ok_or(EditionError::MissingField("eid"))?, "eid")?;
195    // Digit-only: `u64::from_str` accepts a leading `+`, so "+5"/"5" would fold to
196    // one version as distinct signed inners (a convergence fork). Shared v1/v2 grammar.
197    let ev_raw = get(TAG_EVERSION).ok_or(EditionError::MissingField("ev"))?;
198    if !is_tag_decimal(&ev_raw) {
199        return Err(EditionError::BadField("ev"));
200    }
201    let version: u64 = ev_raw.parse().map_err(|_| EditionError::BadField("ev"))?;
202    let prev_hash = match get(TAG_EPREV) {
203        Some(h) => Some(decode_hash(&h, "ep")?),
204        None => None,
205    };
206    let content = inner.content.clone();
207    let self_hash = version::edition_hash(&entity_id, version, prev_hash.as_ref(), content.as_bytes());
208    Ok(ParsedEdition {
209        author: inner.pubkey,
210        vsk,
211        entity_id,
212        version,
213        prev_hash,
214        content,
215        self_hash,
216        created_at: inner.created_at.as_secs(),
217        inner_id: inner.id.to_bytes(),
218        authority: AuthorityCitation::from_tags(&inner.tags),
219    })
220}
221
222impl ParsedEdition {
223    /// The [`version::Edition`] view used by [`version::fold`].
224    pub fn to_fold_edition(&self) -> version::Edition {
225        version::Edition {
226            version: self.version,
227            prev_hash: self.prev_hash,
228            self_hash: self.self_hash,
229            created_at: self.created_at,
230            tiebreak_id: self.inner_id,
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    const VSK_GRANT: &str = "3";
240
241    fn eid() -> [u8; 32] {
242        [0x42; 32]
243    }
244
245    #[test]
246    fn round_trips_authorship_version_and_chain_hash() {
247        let actor = Keys::generate();
248        let prev = version::edition_hash(&eid(), 1, None, b"{}");
249        let inner = build_edition_inner(actor.public_key(), VSK_GRANT, &eid(), 2, Some(&prev), "{\"role_ids\":[]}", 1_700_000_000, None)
250            .finalize(&actor)
251            .unwrap();
252
253        let parsed = parse_edition_inner(&inner).expect("valid edition parses");
254        assert_eq!(parsed.author, actor.public_key(), "authorship = the real signer");
255        assert_eq!(parsed.vsk, VSK_GRANT);
256        assert_eq!(parsed.entity_id, eid());
257        assert_eq!(parsed.version, 2);
258        assert_eq!(parsed.prev_hash, Some(prev));
259        assert_eq!(parsed.created_at, 1_700_000_000);
260        // self_hash matches the canonical recomputation (what the next edition will cite).
261        assert_eq!(
262            parsed.self_hash,
263            version::edition_hash(&eid(), 2, Some(&prev), b"{\"role_ids\":[]}")
264        );
265        // Folds into a version::Edition cleanly.
266        let fe = parsed.to_fold_edition();
267        assert_eq!(fe.version, 2);
268        assert_eq!(fe.prev_hash, Some(prev));
269    }
270
271    #[test]
272    fn authority_citation_round_trips_on_an_edition() {
273        // A delegated admin's edition carries the pinned authority citation; it survives sign→parse,
274        // covered by the inner signature, and does NOT alter the chain self_hash (per-action metadata).
275        let actor = Keys::generate();
276        let cite = AuthorityCitation { entity_id: [0xab; 32], version: 7, edition_hash: [0xcd; 32] };
277        let inner = build_edition_inner(actor.public_key(), VSK_GRANT, &eid(), 1, None, "{}", 100, Some(&cite))
278            .finalize(&actor)
279            .unwrap();
280        let parsed = parse_edition_inner(&inner).unwrap();
281        assert_eq!(parsed.authority.as_ref(), Some(&cite), "citation round-trips");
282        // self_hash is over (entity, version, prev, content) only — the citation doesn't perturb it.
283        assert_eq!(parsed.self_hash, version::edition_hash(&eid(), 1, None, b"{}"));
284
285        // An uncited edition (owner-signed) parses with authority == None.
286        let owner = build_edition_inner(actor.public_key(), VSK_GRANT, &eid(), 1, None, "{}", 100, None)
287            .finalize(&actor)
288            .unwrap();
289        assert_eq!(parse_edition_inner(&owner).unwrap().authority, None);
290    }
291
292    #[test]
293    fn authority_citation_tag_layout_is_frozen() {
294        // FROZEN wire layout: the citation rides as a 4-element `vac` tag
295        // `["vac", <entity hex>, <version>, <edition-hash hex>]`. A change here reshuffles how every
296        // verifier reads pinned authority, so pin the exact shape (not just a round-trip).
297        let cite = AuthorityCitation { entity_id: [0x11; 32], version: 9, edition_hash: [0x22; 32] };
298        let tag = cite.to_tag();
299        let s = tag.as_slice();
300        assert_eq!(s.len(), 4, "vac is a 4-element tag");
301        assert_eq!(s[0], TAG_AUTHORITY_CITATION);
302        assert_eq!(s[1], "11".repeat(32), "entity id is lowercase hex");
303        assert_eq!(s[2], "9", "version is the decimal string");
304        assert_eq!(s[3], "22".repeat(32), "edition hash is lowercase hex");
305    }
306
307    #[test]
308    fn genesis_edition_has_no_prev() {
309        let actor = Keys::generate();
310        let inner = build_edition_inner(actor.public_key(), "1", &eid(), 1, None, "{}", 100, None)
311            .finalize(&actor)
312            .unwrap();
313        let parsed = parse_edition_inner(&inner).unwrap();
314        assert_eq!(parsed.prev_hash, None, "first edition cites no predecessor");
315        assert_eq!(parsed.version, 1);
316    }
317
318    #[test]
319    fn tampered_content_fails_verification() {
320        // Re-sign integrity: flipping the content after signing breaks the inner Schnorr sig.
321        let actor = Keys::generate();
322        let inner = build_edition_inner(actor.public_key(), "3", &eid(), 1, None, "{\"a\":1}", 100, None)
323            .finalize(&actor)
324            .unwrap();
325        let mut json: serde_json::Value = serde_json::from_str(&inner.as_json()).unwrap();
326        json["content"] = serde_json::Value::String("{\"a\":2}".into()); // tamper
327        let tampered: Event = serde_json::from_value(json).unwrap();
328        assert!(matches!(parse_edition_inner(&tampered), Err(EditionError::BadSignature)));
329    }
330
331    #[test]
332    fn missing_required_field_is_rejected_not_panicked() {
333        // An inner event lacking the entity-id tag is a parse error, never a panic.
334        let actor = Keys::generate();
335        let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_CONTROL), "{}")
336            .tags([Tag::custom("vsk", ["3".to_string()])])
337            .finalize(&actor)
338            .unwrap();
339        assert!(matches!(parse_edition_inner(&inner), Err(EditionError::MissingField("eid"))));
340    }
341
342    #[test]
343    fn duplicate_authority_tag_is_rejected() {
344        // A duplicate of ANY of the 5 authority tags (vsk/eid/ev/ep/vac) makes signed-event → canonical
345        // bytes ambiguous (clients could pick different ones) → chain divergence, so it must be rejected.
346        // Parameterized across all 5 — a regression dropping any one from the dedup loop is caught.
347        let actor = Keys::generate();
348        let hash = crate::simd::hex::bytes_to_hex_32(&[0xAB; 32]);
349        let base = || -> Vec<Tag> {
350            vec![
351                Tag::custom("vsk", ["1".to_string()]),
352                Tag::custom("eid", [crate::simd::hex::bytes_to_hex_32(&eid())]),
353                Tag::custom("ev", ["1".to_string()]),
354                Tag::custom("ep", [hash.clone()]),
355                Tag::custom("vac", [crate::simd::hex::bytes_to_hex_32(&eid()), "1".to_string(), hash.clone()]),
356            ]
357        };
358        let build = |tags: Vec<Tag>| EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_CONTROL), "{}")
359            .tags(tags).finalize(&actor).unwrap();
360        assert!(parse_edition_inner(&build(base())).is_ok(), "a clean 5-tag base edition parses");
361        for name in ["vsk", "eid", "ev", "ep", "vac"] {
362            let mut tags = base();
363            let dup = tags.iter().find(|t| t.as_slice().first().map(|s| s == name).unwrap_or(false)).cloned().unwrap();
364            tags.push(dup);
365            assert!(
366                matches!(parse_edition_inner(&build(tags)), Err(EditionError::BadField("duplicate authority tag"))),
367                "a duplicate `{name}` tag must be rejected"
368            );
369        }
370    }
371}