1use 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";
25pub const TAG_AUTHORITY_CITATION: &str = "vac";
34
35pub 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#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51pub struct AuthorityCitation {
52 pub entity_id: [u8; 32],
54 pub version: u64,
56 pub edition_hash: [u8; 32],
58}
59
60impl AuthorityCitation {
61 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 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 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#[allow(clippy::too_many_arguments)]
106pub fn build_edition_inner(
107 author: PublicKey,
108 vsk: &str,
109 entity_id: &[u8; 32],
110 version: u64,
111 prev_hash: Option<&[u8; 32]>,
112 content: &str,
113 created_at_secs: u64,
114 authority: Option<&AuthorityCitation>,
115) -> UnsignedEvent {
116 let mut tags = vec![
117 Tag::custom(TAG_SUBKIND, [vsk.to_string()]),
118 Tag::custom(TAG_ENTITY, [crate::simd::hex::bytes_to_hex_32(entity_id)]),
119 Tag::custom(TAG_EVERSION, [version.to_string()]),
120 Tag::custom(TAG_VERSION, [PROTOCOL_VERSION.to_string()]),
121 ];
122 if let Some(p) = prev_hash {
123 tags.push(Tag::custom(TAG_EPREV, [crate::simd::hex::bytes_to_hex_32(p)]));
124 }
125 if let Some(a) = authority {
129 tags.push(a.to_tag());
130 }
131 EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_CONTROL), content)
132 .tags(tags)
133 .custom_created_at(Timestamp::from_secs(created_at_secs))
134 .finalize_unsigned_with_id(author)
135}
136
137#[derive(Clone, Debug)]
139pub struct ParsedEdition {
140 pub author: PublicKey,
142 pub vsk: String,
143 pub entity_id: [u8; 32],
144 pub version: u64,
145 pub prev_hash: Option<[u8; 32]>,
146 pub content: String,
147 pub self_hash: [u8; 32],
149 pub created_at: u64,
150 pub inner_id: [u8; 32],
151 pub authority: Option<AuthorityCitation>,
154}
155
156#[derive(Debug, PartialEq, Eq)]
157pub enum EditionError {
158 BadSignature,
159 MissingField(&'static str),
160 BadField(&'static str),
161}
162
163fn decode_hash(hex: &str, field: &'static str) -> Result<[u8; 32], EditionError> {
164 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
165 return Err(EditionError::BadField(field));
166 }
167 Ok(crate::simd::hex::hex_to_bytes_32(hex))
168}
169
170pub fn parse_edition_inner(inner: &Event) -> Result<ParsedEdition, EditionError> {
174 inner.verify().map_err(|_| EditionError::BadSignature)?;
175 for name in [TAG_SUBKIND, TAG_ENTITY, TAG_EVERSION, TAG_EPREV, TAG_AUTHORITY_CITATION] {
179 let count = inner
180 .tags
181 .iter()
182 .filter(|t| t.as_slice().first().map(|s| s.as_str() == name).unwrap_or(false))
183 .count();
184 if count > 1 {
185 return Err(EditionError::BadField("duplicate authority tag"));
186 }
187 }
188 let get = |name: &str| -> Option<String> {
189 inner.tags.iter().find_map(|t| {
190 let s = t.as_slice();
191 (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
192 })
193 };
194 let vsk = get(TAG_SUBKIND).ok_or(EditionError::MissingField("vsk"))?;
195 let entity_id = decode_hash(&get(TAG_ENTITY).ok_or(EditionError::MissingField("eid"))?, "eid")?;
196 let ev_raw = get(TAG_EVERSION).ok_or(EditionError::MissingField("ev"))?;
199 if !is_tag_decimal(&ev_raw) {
200 return Err(EditionError::BadField("ev"));
201 }
202 let version: u64 = ev_raw.parse().map_err(|_| EditionError::BadField("ev"))?;
203 let prev_hash = match get(TAG_EPREV) {
204 Some(h) => Some(decode_hash(&h, "ep")?),
205 None => None,
206 };
207 let content = inner.content.clone();
208 let self_hash = version::edition_hash(&entity_id, version, prev_hash.as_ref(), content.as_bytes());
209 Ok(ParsedEdition {
210 author: inner.pubkey,
211 vsk,
212 entity_id,
213 version,
214 prev_hash,
215 content,
216 self_hash,
217 created_at: inner.created_at.as_secs(),
218 inner_id: inner.id.to_bytes(),
219 authority: AuthorityCitation::from_tags(&inner.tags),
220 })
221}
222
223impl ParsedEdition {
224 pub fn to_fold_edition(&self) -> version::Edition {
226 version::Edition {
227 version: self.version,
228 prev_hash: self.prev_hash,
229 self_hash: self.self_hash,
230 created_at: self.created_at,
231 tiebreak_id: self.inner_id,
232 }
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 const VSK_GRANT: &str = "3";
241
242 fn eid() -> [u8; 32] {
243 [0x42; 32]
244 }
245
246 #[test]
247 fn round_trips_authorship_version_and_chain_hash() {
248 let actor = Keys::generate();
249 let prev = version::edition_hash(&eid(), 1, None, b"{}");
250 let inner = build_edition_inner(actor.public_key(), VSK_GRANT, &eid(), 2, Some(&prev), "{\"role_ids\":[]}", 1_700_000_000, None)
251 .finalize(&actor)
252 .unwrap();
253
254 let parsed = parse_edition_inner(&inner).expect("valid edition parses");
255 assert_eq!(parsed.author, actor.public_key(), "authorship = the real signer");
256 assert_eq!(parsed.vsk, VSK_GRANT);
257 assert_eq!(parsed.entity_id, eid());
258 assert_eq!(parsed.version, 2);
259 assert_eq!(parsed.prev_hash, Some(prev));
260 assert_eq!(parsed.created_at, 1_700_000_000);
261 assert_eq!(
263 parsed.self_hash,
264 version::edition_hash(&eid(), 2, Some(&prev), b"{\"role_ids\":[]}")
265 );
266 let fe = parsed.to_fold_edition();
268 assert_eq!(fe.version, 2);
269 assert_eq!(fe.prev_hash, Some(prev));
270 }
271
272 #[test]
273 fn authority_citation_round_trips_on_an_edition() {
274 let actor = Keys::generate();
277 let cite = AuthorityCitation { entity_id: [0xab; 32], version: 7, edition_hash: [0xcd; 32] };
278 let inner = build_edition_inner(actor.public_key(), VSK_GRANT, &eid(), 1, None, "{}", 100, Some(&cite))
279 .finalize(&actor)
280 .unwrap();
281 let parsed = parse_edition_inner(&inner).unwrap();
282 assert_eq!(parsed.authority.as_ref(), Some(&cite), "citation round-trips");
283 assert_eq!(parsed.self_hash, version::edition_hash(&eid(), 1, None, b"{}"));
285
286 let owner = build_edition_inner(actor.public_key(), VSK_GRANT, &eid(), 1, None, "{}", 100, None)
288 .finalize(&actor)
289 .unwrap();
290 assert_eq!(parse_edition_inner(&owner).unwrap().authority, None);
291 }
292
293 #[test]
294 fn authority_citation_tag_layout_is_frozen() {
295 let cite = AuthorityCitation { entity_id: [0x11; 32], version: 9, edition_hash: [0x22; 32] };
299 let tag = cite.to_tag();
300 let s = tag.as_slice();
301 assert_eq!(s.len(), 4, "vac is a 4-element tag");
302 assert_eq!(s[0], TAG_AUTHORITY_CITATION);
303 assert_eq!(s[1], "11".repeat(32), "entity id is lowercase hex");
304 assert_eq!(s[2], "9", "version is the decimal string");
305 assert_eq!(s[3], "22".repeat(32), "edition hash is lowercase hex");
306 }
307
308 #[test]
309 fn genesis_edition_has_no_prev() {
310 let actor = Keys::generate();
311 let inner = build_edition_inner(actor.public_key(), "1", &eid(), 1, None, "{}", 100, None)
312 .finalize(&actor)
313 .unwrap();
314 let parsed = parse_edition_inner(&inner).unwrap();
315 assert_eq!(parsed.prev_hash, None, "first edition cites no predecessor");
316 assert_eq!(parsed.version, 1);
317 }
318
319 #[test]
320 fn tampered_content_fails_verification() {
321 let actor = Keys::generate();
323 let inner = build_edition_inner(actor.public_key(), "3", &eid(), 1, None, "{\"a\":1}", 100, None)
324 .finalize(&actor)
325 .unwrap();
326 let mut json: serde_json::Value = serde_json::from_str(&inner.as_json()).unwrap();
327 json["content"] = serde_json::Value::String("{\"a\":2}".into()); let tampered: Event = serde_json::from_value(json).unwrap();
329 assert!(matches!(parse_edition_inner(&tampered), Err(EditionError::BadSignature)));
330 }
331
332 #[test]
333 fn missing_required_field_is_rejected_not_panicked() {
334 let actor = Keys::generate();
336 let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_CONTROL), "{}")
337 .tags([Tag::custom("vsk", ["3".to_string()])])
338 .finalize(&actor)
339 .unwrap();
340 assert!(matches!(parse_edition_inner(&inner), Err(EditionError::MissingField("eid"))));
341 }
342
343 #[test]
344 fn duplicate_authority_tag_is_rejected() {
345 let actor = Keys::generate();
349 let hash = crate::simd::hex::bytes_to_hex_32(&[0xAB; 32]);
350 let base = || -> Vec<Tag> {
351 vec![
352 Tag::custom("vsk", ["1".to_string()]),
353 Tag::custom("eid", [crate::simd::hex::bytes_to_hex_32(&eid())]),
354 Tag::custom("ev", ["1".to_string()]),
355 Tag::custom("ep", [hash.clone()]),
356 Tag::custom("vac", [crate::simd::hex::bytes_to_hex_32(&eid()), "1".to_string(), hash.clone()]),
357 ]
358 };
359 let build = |tags: Vec<Tag>| EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_CONTROL), "{}")
360 .tags(tags).finalize(&actor).unwrap();
361 assert!(parse_edition_inner(&build(base())).is_ok(), "a clean 5-tag base edition parses");
362 for name in ["vsk", "eid", "ev", "ep", "vac"] {
363 let mut tags = base();
364 let dup = tags.iter().find(|t| t.as_slice().first().map(|s| s == name).unwrap_or(false)).cloned().unwrap();
365 tags.push(dup);
366 assert!(
367 matches!(parse_edition_inner(&build(tags)), Err(EditionError::BadField("duplicate authority tag"))),
368 "a duplicate `{name}` tag must be rejected"
369 );
370 }
371 }
372}