lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

draft.rs (24506B)


      1 #![forbid(unsafe_code)]
      2 
      3 #[cfg(not(feature = "std"))]
      4 use alloc::{
      5     borrow::ToOwned,
      6     string::{String, ToString},
      7     vec::Vec,
      8 };
      9 
     10 #[cfg(feature = "std")]
     11 use std::{
     12     borrow::ToOwned,
     13     string::{String, ToString},
     14     vec::Vec,
     15 };
     16 
     17 use crate::RadrootsNostrEvent;
     18 use crate::contract::{RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, event_contract};
     19 use crate::ids::{
     20     RadrootsEventId, RadrootsEventSignature, RadrootsIdParseError, RadrootsPublicKey,
     21 };
     22 use core::fmt;
     23 use sha2::{Digest, Sha256};
     24 
     25 #[derive(Clone, Debug, PartialEq, Eq)]
     26 pub enum RadrootsDraftError {
     27     UnknownContract(String),
     28     ContractKindMismatch {
     29         contract_id: String,
     30         expected_kind: u32,
     31         actual_kind: u32,
     32     },
     33     SignedEventPubkeyMismatch {
     34         expected_pubkey: String,
     35         actual_pubkey: String,
     36     },
     37     SignedEventIdMismatch {
     38         expected_event_id: String,
     39         actual_event_id: String,
     40     },
     41     SignedEventCreatedAtMismatch {
     42         expected_created_at: u32,
     43         actual_created_at: u32,
     44     },
     45     SignedEventKindMismatch {
     46         expected_kind: u32,
     47         actual_kind: u32,
     48     },
     49     SignedEventTagsMismatch {
     50         expected_len: usize,
     51         actual_len: usize,
     52     },
     53     SignedEventContentMismatch {
     54         expected_len: usize,
     55         actual_len: usize,
     56     },
     57     SignedEventComputedIdMismatch {
     58         expected_event_id: String,
     59         computed_event_id: String,
     60     },
     61     IdParse(RadrootsIdParseError),
     62     JsonString(String),
     63 }
     64 
     65 impl fmt::Display for RadrootsDraftError {
     66     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     67         match self {
     68             Self::UnknownContract(contract_id) => {
     69                 write!(f, "unknown event contract `{contract_id}`")
     70             }
     71             Self::ContractKindMismatch {
     72                 contract_id,
     73                 expected_kind,
     74                 actual_kind,
     75             } => write!(
     76                 f,
     77                 "event contract `{contract_id}` expects kind {expected_kind}, got {actual_kind}"
     78             ),
     79             Self::SignedEventPubkeyMismatch {
     80                 expected_pubkey,
     81                 actual_pubkey,
     82             } => write!(
     83                 f,
     84                 "signed event pubkey mismatch: expected {expected_pubkey}, got {actual_pubkey}"
     85             ),
     86             Self::SignedEventIdMismatch {
     87                 expected_event_id,
     88                 actual_event_id,
     89             } => write!(
     90                 f,
     91                 "signed event id mismatch: expected {expected_event_id}, got {actual_event_id}"
     92             ),
     93             Self::SignedEventCreatedAtMismatch {
     94                 expected_created_at,
     95                 actual_created_at,
     96             } => write!(
     97                 f,
     98                 "signed event created_at mismatch: expected {expected_created_at}, got {actual_created_at}"
     99             ),
    100             Self::SignedEventKindMismatch {
    101                 expected_kind,
    102                 actual_kind,
    103             } => write!(
    104                 f,
    105                 "signed event kind mismatch: expected {expected_kind}, got {actual_kind}"
    106             ),
    107             Self::SignedEventTagsMismatch {
    108                 expected_len,
    109                 actual_len,
    110             } => write!(
    111                 f,
    112                 "signed event tags mismatch: expected {expected_len} tags, got {actual_len} tags"
    113             ),
    114             Self::SignedEventContentMismatch {
    115                 expected_len,
    116                 actual_len,
    117             } => write!(
    118                 f,
    119                 "signed event content mismatch: expected {expected_len} bytes, got {actual_len} bytes"
    120             ),
    121             Self::SignedEventComputedIdMismatch {
    122                 expected_event_id,
    123                 computed_event_id,
    124             } => write!(
    125                 f,
    126                 "signed event computed id mismatch: expected {expected_event_id}, computed {computed_event_id}"
    127             ),
    128             Self::IdParse(error) => write!(f, "{error}"),
    129             Self::JsonString(error) => write!(f, "json string serialization failed: {error}"),
    130         }
    131     }
    132 }
    133 
    134 #[cfg(feature = "std")]
    135 impl std::error::Error for RadrootsDraftError {}
    136 
    137 impl From<RadrootsIdParseError> for RadrootsDraftError {
    138     fn from(value: RadrootsIdParseError) -> Self {
    139         Self::IdParse(value)
    140     }
    141 }
    142 
    143 impl From<serde_json::Error> for RadrootsDraftError {
    144     fn from(value: serde_json::Error) -> Self {
    145         Self::JsonString(value.to_string())
    146     }
    147 }
    148 
    149 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    150 #[derive(Clone, Debug, PartialEq, Eq)]
    151 pub struct RadrootsFrozenEventDraft {
    152     pub contract_id: String,
    153     pub contract_registry_version: u32,
    154     pub kind: u32,
    155     pub created_at: u32,
    156     pub tags: Vec<Vec<String>>,
    157     pub content: String,
    158     pub expected_pubkey: String,
    159     pub expected_event_id: String,
    160 }
    161 
    162 impl RadrootsFrozenEventDraft {
    163     pub fn new(
    164         contract_id: impl Into<String>,
    165         kind: u32,
    166         created_at: u32,
    167         tags: Vec<Vec<String>>,
    168         content: impl Into<String>,
    169         expected_pubkey: impl AsRef<str>,
    170     ) -> Result<Self, RadrootsDraftError> {
    171         let contract_id = contract_id.into();
    172         let contract = event_contract(&contract_id)
    173             .ok_or_else(|| RadrootsDraftError::UnknownContract(contract_id.clone()))?;
    174         if contract.kind != kind {
    175             return Err(RadrootsDraftError::ContractKindMismatch {
    176                 contract_id,
    177                 expected_kind: contract.kind,
    178                 actual_kind: kind,
    179             });
    180         }
    181         let expected_pubkey = RadrootsPublicKey::parse(expected_pubkey.as_ref())?.into_string();
    182         let content = content.into();
    183         let expected_event_id =
    184             compute_nip01_event_id(expected_pubkey.as_str(), created_at, kind, &tags, &content)?
    185                 .into_string();
    186         Ok(Self {
    187             contract_id: contract.id.to_owned(),
    188             contract_registry_version: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION,
    189             kind,
    190             created_at,
    191             tags,
    192             content,
    193             expected_pubkey,
    194             expected_event_id,
    195         })
    196     }
    197 
    198     pub fn nip01_preimage(&self) -> Result<String, RadrootsDraftError> {
    199         nip01_event_id_preimage(
    200             self.expected_pubkey.as_str(),
    201             self.created_at,
    202             self.kind,
    203             &self.tags,
    204             self.content.as_str(),
    205         )
    206     }
    207 }
    208 
    209 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    210 #[derive(Clone, Debug, PartialEq, Eq)]
    211 pub struct RadrootsSignedNostrEventParts {
    212     pub id: String,
    213     pub pubkey: String,
    214     pub created_at: u32,
    215     pub kind: u32,
    216     pub tags: Vec<Vec<String>>,
    217     pub content: String,
    218     pub sig: String,
    219     pub raw_json: String,
    220 }
    221 
    222 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    223 #[derive(Clone, Debug, PartialEq, Eq)]
    224 pub struct RadrootsSignedNostrEvent {
    225     pub id: String,
    226     pub pubkey: String,
    227     pub created_at: u32,
    228     pub kind: u32,
    229     pub tags: Vec<Vec<String>>,
    230     pub content: String,
    231     pub sig: String,
    232     pub raw_json: String,
    233 }
    234 
    235 impl RadrootsSignedNostrEvent {
    236     pub fn new(parts: RadrootsSignedNostrEventParts) -> Result<Self, RadrootsDraftError> {
    237         let id = RadrootsEventId::parse(parts.id)?.into_string();
    238         let pubkey = RadrootsPublicKey::parse(parts.pubkey)?.into_string();
    239         let sig = RadrootsEventSignature::parse(parts.sig)?.into_string();
    240         Ok(Self {
    241             id,
    242             pubkey,
    243             created_at: parts.created_at,
    244             kind: parts.kind,
    245             tags: parts.tags,
    246             content: parts.content,
    247             sig,
    248             raw_json: parts.raw_json,
    249         })
    250     }
    251 
    252     pub fn from_event(
    253         event: RadrootsNostrEvent,
    254         raw_json: impl Into<String>,
    255     ) -> Result<Self, RadrootsDraftError> {
    256         Self::new(RadrootsSignedNostrEventParts {
    257             id: event.id,
    258             pubkey: event.author,
    259             created_at: event.created_at,
    260             kind: event.kind,
    261             tags: event.tags,
    262             content: event.content,
    263             sig: event.sig,
    264             raw_json: raw_json.into(),
    265         })
    266     }
    267 }
    268 
    269 pub fn validate_signed_nostr_event_matches_draft(
    270     signed_event: &RadrootsSignedNostrEvent,
    271     draft: &RadrootsFrozenEventDraft,
    272 ) -> Result<(), RadrootsDraftError> {
    273     if signed_event.pubkey.as_str() != draft.expected_pubkey.as_str() {
    274         return Err(RadrootsDraftError::SignedEventPubkeyMismatch {
    275             expected_pubkey: draft.expected_pubkey.clone(),
    276             actual_pubkey: signed_event.pubkey.clone(),
    277         });
    278     }
    279     if signed_event.id.as_str() != draft.expected_event_id.as_str() {
    280         return Err(RadrootsDraftError::SignedEventIdMismatch {
    281             expected_event_id: draft.expected_event_id.clone(),
    282             actual_event_id: signed_event.id.clone(),
    283         });
    284     }
    285     if signed_event.created_at != draft.created_at {
    286         return Err(RadrootsDraftError::SignedEventCreatedAtMismatch {
    287             expected_created_at: draft.created_at,
    288             actual_created_at: signed_event.created_at,
    289         });
    290     }
    291     if signed_event.kind != draft.kind {
    292         return Err(RadrootsDraftError::SignedEventKindMismatch {
    293             expected_kind: draft.kind,
    294             actual_kind: signed_event.kind,
    295         });
    296     }
    297     if signed_event.tags != draft.tags {
    298         return Err(RadrootsDraftError::SignedEventTagsMismatch {
    299             expected_len: draft.tags.len(),
    300             actual_len: signed_event.tags.len(),
    301         });
    302     }
    303     if signed_event.content != draft.content {
    304         return Err(RadrootsDraftError::SignedEventContentMismatch {
    305             expected_len: draft.content.len(),
    306             actual_len: signed_event.content.len(),
    307         });
    308     }
    309     let computed_event_id = compute_nip01_event_id(
    310         signed_event.pubkey.as_str(),
    311         signed_event.created_at,
    312         signed_event.kind,
    313         &signed_event.tags,
    314         signed_event.content.as_str(),
    315     )?
    316     .into_string();
    317     if computed_event_id.as_str() != signed_event.id.as_str() {
    318         return Err(RadrootsDraftError::SignedEventComputedIdMismatch {
    319             expected_event_id: signed_event.id.clone(),
    320             computed_event_id,
    321         });
    322     }
    323     Ok(())
    324 }
    325 
    326 pub fn compute_nip01_event_id(
    327     pubkey: &str,
    328     created_at: u32,
    329     kind: u32,
    330     tags: &[Vec<String>],
    331     content: &str,
    332 ) -> Result<RadrootsEventId, RadrootsDraftError> {
    333     let pubkey = RadrootsPublicKey::parse(pubkey)?;
    334     let preimage = nip01_event_id_preimage(pubkey.as_str(), created_at, kind, tags, content)?;
    335     let digest = Sha256::digest(preimage.as_bytes());
    336     let event_id = hex::encode(digest);
    337     Ok(RadrootsEventId::parse(event_id)?)
    338 }
    339 
    340 pub fn nip01_event_id_preimage(
    341     pubkey: &str,
    342     created_at: u32,
    343     kind: u32,
    344     tags: &[Vec<String>],
    345     content: &str,
    346 ) -> Result<String, RadrootsDraftError> {
    347     let mut preimage = String::new();
    348     preimage.push_str("[0,");
    349     push_json_string(&mut preimage, pubkey)?;
    350     preimage.push(',');
    351     preimage.push_str(created_at.to_string().as_str());
    352     preimage.push(',');
    353     preimage.push_str(kind.to_string().as_str());
    354     preimage.push_str(",[");
    355     for (tag_index, tag) in tags.iter().enumerate() {
    356         if tag_index > 0 {
    357             preimage.push(',');
    358         }
    359         preimage.push('[');
    360         for (value_index, value) in tag.iter().enumerate() {
    361             if value_index > 0 {
    362                 preimage.push(',');
    363             }
    364             push_json_string(&mut preimage, value)?;
    365         }
    366         preimage.push(']');
    367     }
    368     preimage.push_str("],");
    369     push_json_string(&mut preimage, content)?;
    370     preimage.push(']');
    371     Ok(preimage)
    372 }
    373 
    374 fn push_json_string(target: &mut String, value: &str) -> Result<(), RadrootsDraftError> {
    375     target.push_str(serde_json::to_string(value)?.as_str());
    376     Ok(())
    377 }
    378 
    379 #[cfg(test)]
    380 mod tests {
    381     use super::*;
    382     use crate::kinds::{KIND_POST, KIND_PROFILE};
    383 
    384     fn hex_64(character: char) -> String {
    385         core::iter::repeat_n(character, 64).collect()
    386     }
    387 
    388     fn signed_event_for_draft(draft: &RadrootsFrozenEventDraft) -> RadrootsSignedNostrEvent {
    389         RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
    390             id: draft.expected_event_id.clone(),
    391             pubkey: draft.expected_pubkey.clone(),
    392             created_at: draft.created_at,
    393             kind: draft.kind,
    394             tags: draft.tags.clone(),
    395             content: draft.content.clone(),
    396             sig: "b".repeat(128),
    397             raw_json: "{}".to_owned(),
    398         })
    399         .expect("signed event")
    400     }
    401 
    402     fn post_draft() -> RadrootsFrozenEventDraft {
    403         RadrootsFrozenEventDraft::new(
    404             "radroots.social.post.v1",
    405             KIND_POST,
    406             1_700_000_000,
    407             vec![vec!["t".to_owned(), "soil".to_owned()]],
    408             "hello",
    409             "a".repeat(64),
    410         )
    411         .expect("draft")
    412     }
    413 
    414     #[test]
    415     fn frozen_draft_computes_expected_event_id() {
    416         let draft = RadrootsFrozenEventDraft::new(
    417             "radroots.social.post.v1",
    418             KIND_POST,
    419             1_700_000_000,
    420             vec![
    421                 vec!["t".to_owned(), "soil".to_owned()],
    422                 vec!["p".to_owned(), hex_64('b')],
    423             ],
    424             "hello",
    425             hex_64('a'),
    426         )
    427         .expect("draft");
    428 
    429         assert_eq!(
    430             draft.nip01_preimage().expect("preimage"),
    431             "[0,\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",1700000000,1,[[\"t\",\"soil\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"hello\"]"
    432         );
    433         assert_eq!(
    434             draft.expected_event_id,
    435             "59d2486ef5557e0e317127de55005f2863361ad4041277ae523a869f2294cf9c"
    436         );
    437     }
    438 
    439     #[test]
    440     fn deterministic_event_id_changes_when_preimage_changes() {
    441         let tags = vec![vec!["t".to_owned(), "soil".to_owned()]];
    442         let base = compute_nip01_event_id(hex_64('a').as_str(), 1, KIND_POST, &tags, "hello")
    443             .expect("base");
    444         let pubkey_changed =
    445             compute_nip01_event_id(hex_64('b').as_str(), 1, KIND_POST, &tags, "hello")
    446                 .expect("pubkey");
    447         let time_changed =
    448             compute_nip01_event_id(hex_64('a').as_str(), 2, KIND_POST, &tags, "hello")
    449                 .expect("time");
    450         let kind_changed =
    451             compute_nip01_event_id(hex_64('a').as_str(), 1, KIND_PROFILE, &tags, "hello")
    452                 .expect("kind");
    453         let tag_order_changed = compute_nip01_event_id(
    454             hex_64('a').as_str(),
    455             1,
    456             KIND_POST,
    457             &[
    458                 vec!["p".to_owned(), hex_64('c')],
    459                 vec!["t".to_owned(), "soil".to_owned()],
    460             ],
    461             "hello",
    462         )
    463         .expect("tag order");
    464         let content_changed =
    465             compute_nip01_event_id(hex_64('a').as_str(), 1, KIND_POST, &tags, "hello!")
    466                 .expect("content");
    467 
    468         assert_ne!(base, pubkey_changed);
    469         assert_ne!(base, time_changed);
    470         assert_ne!(base, kind_changed);
    471         assert_ne!(base, tag_order_changed);
    472         assert_ne!(base, content_changed);
    473     }
    474 
    475     #[test]
    476     fn profile_golden_event_id_is_stable() {
    477         let event_id = compute_nip01_event_id(hex_64('c').as_str(), 1_700_000_001, 0, &[], "{}")
    478             .expect("event id");
    479 
    480         assert_eq!(
    481             event_id.as_str(),
    482             "2a15e33622a155ae231b28bebe390869e67a0e228f77ecfcd652b1ce180a9dde"
    483         );
    484     }
    485 
    486     #[test]
    487     fn draft_constructor_rejects_unknown_contract_and_kind_mismatch() {
    488         let unknown =
    489             RadrootsFrozenEventDraft::new("missing", KIND_POST, 1, Vec::new(), "", hex_64('a'))
    490                 .expect_err("unknown contract");
    491         assert!(matches!(unknown, RadrootsDraftError::UnknownContract(_)));
    492 
    493         let mismatch = RadrootsFrozenEventDraft::new(
    494             "radroots.social.post.v1",
    495             KIND_PROFILE,
    496             1,
    497             Vec::new(),
    498             "",
    499             hex_64('a'),
    500         )
    501         .expect_err("kind mismatch");
    502         assert!(matches!(
    503             mismatch,
    504             RadrootsDraftError::ContractKindMismatch { .. }
    505         ));
    506 
    507         let invalid_pubkey = RadrootsFrozenEventDraft::new(
    508             "radroots.social.post.v1",
    509             KIND_POST,
    510             1,
    511             Vec::new(),
    512             "",
    513             "not-hex",
    514         )
    515         .expect_err("invalid pubkey");
    516         assert!(matches!(invalid_pubkey, RadrootsDraftError::IdParse(_)));
    517     }
    518 
    519     #[test]
    520     fn signed_event_validates_ids_and_roundtrips_with_serde() {
    521         let signed = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
    522             id: hex_64('d'),
    523             pubkey: hex_64('e'),
    524             created_at: 10,
    525             kind: KIND_POST,
    526             tags: Vec::new(),
    527             content: "hello".to_owned(),
    528             sig: "f".repeat(128),
    529             raw_json: "{\"id\":\"fixture\"}".to_owned(),
    530         })
    531         .expect("signed event");
    532         let json = serde_json::to_string(&signed).expect("serialize");
    533         let decoded: RadrootsSignedNostrEvent = serde_json::from_str(&json).expect("deserialize");
    534 
    535         assert_eq!(decoded, signed);
    536         assert_eq!(decoded.pubkey, hex_64('e'));
    537     }
    538 
    539     #[test]
    540     fn signed_event_from_nostr_event_validates_parts() {
    541         let event = RadrootsNostrEvent {
    542             id: hex_64('1'),
    543             author: hex_64('2'),
    544             created_at: 42,
    545             kind: KIND_POST,
    546             tags: vec![vec!["t".to_owned(), "soil".to_owned()]],
    547             content: "hello".to_owned(),
    548             sig: "3".repeat(128),
    549         };
    550         let signed = RadrootsSignedNostrEvent::from_event(event, "{\"id\":\"fixture\"}")
    551             .expect("signed event");
    552 
    553         assert_eq!(signed.id, hex_64('1'));
    554         assert_eq!(signed.pubkey, hex_64('2'));
    555         assert_eq!(signed.sig, "3".repeat(128));
    556         assert_eq!(signed.raw_json, "{\"id\":\"fixture\"}");
    557 
    558         let invalid = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
    559             id: "not-hex".to_owned(),
    560             pubkey: hex_64('e'),
    561             created_at: 10,
    562             kind: KIND_POST,
    563             tags: Vec::new(),
    564             content: String::new(),
    565             sig: "f".repeat(128),
    566             raw_json: "{}".to_owned(),
    567         })
    568         .expect_err("invalid id");
    569         assert!(matches!(invalid, RadrootsDraftError::IdParse(_)));
    570 
    571         let invalid = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
    572             id: hex_64('d'),
    573             pubkey: "not-hex".to_owned(),
    574             created_at: 10,
    575             kind: KIND_POST,
    576             tags: Vec::new(),
    577             content: String::new(),
    578             sig: "f".repeat(128),
    579             raw_json: "{}".to_owned(),
    580         })
    581         .expect_err("invalid pubkey");
    582         assert!(matches!(invalid, RadrootsDraftError::IdParse(_)));
    583 
    584         let invalid = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
    585             id: hex_64('d'),
    586             pubkey: hex_64('e'),
    587             created_at: 10,
    588             kind: KIND_POST,
    589             tags: Vec::new(),
    590             content: String::new(),
    591             sig: "not-hex".to_owned(),
    592             raw_json: "{}".to_owned(),
    593         })
    594         .expect_err("invalid sig");
    595         assert!(matches!(invalid, RadrootsDraftError::IdParse(_)));
    596     }
    597 
    598     #[test]
    599     fn signed_event_validation_accepts_exact_draft_match() {
    600         let draft = post_draft();
    601         let signed = signed_event_for_draft(&draft);
    602 
    603         validate_signed_nostr_event_matches_draft(&signed, &draft).expect("valid signed event");
    604     }
    605 
    606     #[test]
    607     fn signed_event_validation_rejects_draft_mismatches() {
    608         let draft = post_draft();
    609 
    610         let mut signed = signed_event_for_draft(&draft);
    611         signed.pubkey = hex_64('c');
    612         let error =
    613             validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
    614         assert!(matches!(
    615             error,
    616             RadrootsDraftError::SignedEventPubkeyMismatch { .. }
    617         ));
    618 
    619         let mut signed = signed_event_for_draft(&draft);
    620         signed.id = hex_64('d');
    621         let error =
    622             validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
    623         assert!(matches!(
    624             error,
    625             RadrootsDraftError::SignedEventIdMismatch { .. }
    626         ));
    627 
    628         let mut signed = signed_event_for_draft(&draft);
    629         signed.created_at += 1;
    630         let error =
    631             validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
    632         assert!(matches!(
    633             error,
    634             RadrootsDraftError::SignedEventCreatedAtMismatch { .. }
    635         ));
    636 
    637         let mut signed = signed_event_for_draft(&draft);
    638         signed.kind = KIND_PROFILE;
    639         let error =
    640             validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
    641         assert!(matches!(
    642             error,
    643             RadrootsDraftError::SignedEventKindMismatch { .. }
    644         ));
    645 
    646         let mut signed = signed_event_for_draft(&draft);
    647         signed.tags.push(vec!["p".to_owned(), hex_64('e')]);
    648         let error =
    649             validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
    650         assert!(matches!(
    651             error,
    652             RadrootsDraftError::SignedEventTagsMismatch { .. }
    653         ));
    654 
    655         let mut signed = signed_event_for_draft(&draft);
    656         signed.content = "changed".to_owned();
    657         let error =
    658             validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
    659         assert!(matches!(
    660             error,
    661             RadrootsDraftError::SignedEventContentMismatch { .. }
    662         ));
    663 
    664         let mut draft = post_draft();
    665         draft.expected_event_id = hex_64('f');
    666         let signed = signed_event_for_draft(&draft);
    667         let error =
    668             validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
    669         assert!(matches!(
    670             error,
    671             RadrootsDraftError::SignedEventComputedIdMismatch { .. }
    672         ));
    673     }
    674 
    675     #[test]
    676     fn draft_errors_format_all_variants() {
    677         let errors = [
    678             RadrootsDraftError::UnknownContract("missing".to_owned()),
    679             RadrootsDraftError::ContractKindMismatch {
    680                 contract_id: "radroots.social.post.v1".to_owned(),
    681                 expected_kind: KIND_POST,
    682                 actual_kind: KIND_PROFILE,
    683             },
    684             RadrootsDraftError::SignedEventPubkeyMismatch {
    685                 expected_pubkey: hex_64('a'),
    686                 actual_pubkey: hex_64('b'),
    687             },
    688             RadrootsDraftError::SignedEventIdMismatch {
    689                 expected_event_id: hex_64('c'),
    690                 actual_event_id: hex_64('d'),
    691             },
    692             RadrootsDraftError::SignedEventCreatedAtMismatch {
    693                 expected_created_at: 1,
    694                 actual_created_at: 2,
    695             },
    696             RadrootsDraftError::SignedEventKindMismatch {
    697                 expected_kind: KIND_POST,
    698                 actual_kind: KIND_PROFILE,
    699             },
    700             RadrootsDraftError::SignedEventTagsMismatch {
    701                 expected_len: 1,
    702                 actual_len: 2,
    703             },
    704             RadrootsDraftError::SignedEventContentMismatch {
    705                 expected_len: 5,
    706                 actual_len: 7,
    707             },
    708             RadrootsDraftError::SignedEventComputedIdMismatch {
    709                 expected_event_id: hex_64('e'),
    710                 computed_event_id: hex_64('f'),
    711             },
    712             RadrootsDraftError::from(RadrootsIdParseError::Empty),
    713         ];
    714 
    715         for error in errors {
    716             assert!(!error.to_string().is_empty());
    717         }
    718 
    719         let json_error = serde_json::from_str::<String>("{").expect_err("json error");
    720         let error = RadrootsDraftError::from(json_error);
    721         assert!(
    722             error
    723                 .to_string()
    724                 .contains("json string serialization failed")
    725         );
    726     }
    727 
    728     #[test]
    729     fn event_id_computation_rejects_invalid_pubkeys() {
    730         let error =
    731             compute_nip01_event_id("not-hex", 1, KIND_POST, &[], "").expect_err("invalid pubkey");
    732         assert!(matches!(error, RadrootsDraftError::IdParse(_)));
    733     }
    734 }