lib

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

comment.rs (35063B)


      1 #[cfg(not(feature = "std"))]
      2 use alloc::{
      3     format,
      4     string::{String, ToString},
      5 };
      6 use core::fmt;
      7 
      8 use crate::{
      9     envelope::kind::{
     10         KIND_ARTICLE, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_CLASSIFIED_LISTING,
     11         KIND_COMMENT,
     12     },
     13     id::{
     14         AddressableCoordinate, AddressableCoordinateParts, EventId, ParseError, parse_public_key,
     15     },
     16     tag::relay_hint::NostrRelayHint,
     17     wire::{
     18         DEFAULT_CONTENT_MAX_BYTES, DEFAULT_RAW_JSON_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES,
     19         DEFAULT_TAG_MAX_COUNT, DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT, DEFAULT_TAG_TOTAL_MAX_BYTES,
     20     },
     21 };
     22 use radroots_identity::PublicKey;
     23 
     24 pub const RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES: usize = DEFAULT_CONTENT_MAX_BYTES;
     25 pub const RADROOTS_NIP22_COMMENT_TAG_MAX_COUNT: usize = DEFAULT_TAG_MAX_COUNT;
     26 pub const RADROOTS_NIP22_COMMENT_TAG_TOTAL_ELEMENT_MAX_COUNT: usize =
     27     DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT;
     28 pub const RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES: usize = DEFAULT_TAG_ELEMENT_MAX_BYTES;
     29 pub const RADROOTS_NIP22_COMMENT_TAG_TOTAL_MAX_BYTES: usize = DEFAULT_TAG_TOTAL_MAX_BYTES;
     30 pub const RADROOTS_NIP22_COMMENT_EVENT_WIRE_MAX_BYTES: usize = DEFAULT_RAW_JSON_MAX_BYTES;
     31 
     32 const RADROOTS_NIP22_COMMENT_SIGNED_EVENT_FIXED_MAX_BYTES: usize = "{\"id\":\"".len()
     33     + 64
     34     + "\",\"pubkey\":\"".len()
     35     + 64
     36     + "\",\"created_at\":".len()
     37     + 20
     38     + ",\"kind\":1111,\"tags\":".len()
     39     + ",\"content\":".len()
     40     + ",\"sig\":\"".len()
     41     + 128
     42     + "\"}".len();
     43 
     44 #[non_exhaustive]
     45 #[derive(Clone, Debug, PartialEq, Eq)]
     46 pub enum Nip22CommentError {
     47     ContentMissing,
     48     ContentTooLarge { max: usize, actual: usize },
     49     RootEventIdInvalid(ParseError),
     50     RootAuthorInvalid(ParseError),
     51     RootCoordinateInvalid(ParseError),
     52     ParentEventIdInvalid(ParseError),
     53     ParentAuthorInvalid(ParseError),
     54     RevisionEventIdInvalid(ParseError),
     55     RootKindUnsupported { actual: u32 },
     56     RelayInvalid(ParseError),
     57     ParentReferenceMismatch,
     58     TagCountExceeded { max: usize, actual: usize },
     59     TagElementCountExceeded { max: usize, actual: usize },
     60     TagElementTooLarge { max: usize, actual: usize },
     61     TagBytesExceeded { max: usize, actual: usize },
     62     EventWireTooLarge { max: usize, actual: usize },
     63 }
     64 
     65 impl Nip22CommentError {
     66     pub const fn code(&self) -> &'static str {
     67         match self {
     68             Self::ContentMissing => "comment_content_missing",
     69             Self::ContentTooLarge { .. } => "comment_content_too_large",
     70             Self::RootEventIdInvalid(_) => "comment_root_event_id_invalid",
     71             Self::RootAuthorInvalid(_) => "comment_root_author_invalid",
     72             Self::RootCoordinateInvalid(_) => "comment_root_coordinate_invalid",
     73             Self::ParentEventIdInvalid(_) => "comment_parent_event_id_invalid",
     74             Self::ParentAuthorInvalid(_) => "comment_parent_author_invalid",
     75             Self::RevisionEventIdInvalid(_) => "comment_revision_event_id_invalid",
     76             Self::RootKindUnsupported { .. } => "comment_root_kind_unsupported",
     77             Self::RelayInvalid(_) => "comment_relay_invalid",
     78             Self::ParentReferenceMismatch => "comment_parent_reference_mismatch",
     79             Self::TagCountExceeded { .. } => "comment_tag_count_exceeded",
     80             Self::TagElementCountExceeded { .. } => "comment_tag_element_count_exceeded",
     81             Self::TagElementTooLarge { .. } => "comment_tag_element_too_large",
     82             Self::TagBytesExceeded { .. } => "comment_tag_bytes_exceeded",
     83             Self::EventWireTooLarge { .. } => "comment_event_wire_too_large",
     84         }
     85     }
     86 }
     87 
     88 impl fmt::Display for Nip22CommentError {
     89     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     90         match self {
     91             Self::ContentMissing => {
     92                 formatter.write_str("authored NIP-22 comment content must be non-whitespace")
     93             }
     94             Self::ContentTooLarge { max, actual } => write!(
     95                 formatter,
     96                 "authored NIP-22 comment content is {actual} bytes; max is {max}"
     97             ),
     98             Self::RootEventIdInvalid(error) => {
     99                 write!(
    100                     formatter,
    101                     "NIP-22 comment root event id is invalid: {error}"
    102                 )
    103             }
    104             Self::RootAuthorInvalid(error) => {
    105                 write!(formatter, "NIP-22 comment root author is invalid: {error}")
    106             }
    107             Self::RootCoordinateInvalid(error) => {
    108                 write!(
    109                     formatter,
    110                     "NIP-22 comment root coordinate is invalid: {error}"
    111                 )
    112             }
    113             Self::ParentEventIdInvalid(error) => {
    114                 write!(
    115                     formatter,
    116                     "NIP-22 comment parent event id is invalid: {error}"
    117                 )
    118             }
    119             Self::ParentAuthorInvalid(error) => {
    120                 write!(
    121                     formatter,
    122                     "NIP-22 comment parent author is invalid: {error}"
    123                 )
    124             }
    125             Self::RevisionEventIdInvalid(error) => {
    126                 write!(
    127                     formatter,
    128                     "NIP-22 comment revision event id is invalid: {error}"
    129                 )
    130             }
    131             Self::RootKindUnsupported { actual } => write!(
    132                 formatter,
    133                 "NIP-22 comment root kind {actual} is outside the supported profile"
    134             ),
    135             Self::RelayInvalid(error) => {
    136                 write!(formatter, "NIP-22 comment relay hint is invalid: {error}")
    137             }
    138             Self::ParentReferenceMismatch => {
    139                 formatter.write_str("nested NIP-22 comment parent must differ from its event root")
    140             }
    141             Self::TagCountExceeded { max, actual } => write!(
    142                 formatter,
    143                 "authored NIP-22 comment has {actual} tags; max is {max}"
    144             ),
    145             Self::TagElementCountExceeded { max, actual } => write!(
    146                 formatter,
    147                 "authored NIP-22 comment has {actual} total tag elements; max is {max}"
    148             ),
    149             Self::TagElementTooLarge { max, actual } => write!(
    150                 formatter,
    151                 "authored NIP-22 comment tag element is {actual} bytes; max is {max}"
    152             ),
    153             Self::TagBytesExceeded { max, actual } => write!(
    154                 formatter,
    155                 "authored NIP-22 comment tag bytes are {actual}; max is {max}"
    156             ),
    157             Self::EventWireTooLarge { max, actual } => write!(
    158                 formatter,
    159                 "authored NIP-22 comment maximum canonical signed event size is {actual} bytes; max is {max}"
    160             ),
    161         }
    162     }
    163 }
    164 
    165 #[cfg(feature = "std")]
    166 impl std::error::Error for Nip22CommentError {
    167     fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
    168         match self {
    169             Self::RootEventIdInvalid(error)
    170             | Self::RootAuthorInvalid(error)
    171             | Self::RootCoordinateInvalid(error)
    172             | Self::ParentEventIdInvalid(error)
    173             | Self::ParentAuthorInvalid(error)
    174             | Self::RevisionEventIdInvalid(error)
    175             | Self::RelayInvalid(error) => Some(error),
    176             _ => None,
    177         }
    178     }
    179 }
    180 
    181 /// Root event kinds supported by the strict Radroots NIP-22 profile.
    182 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    183 pub enum Nip22CommentRootKind {
    184     Article,
    185     ClassifiedListing,
    186     CalendarDateEvent,
    187     CalendarTimeEvent,
    188 }
    189 
    190 impl Nip22CommentRootKind {
    191     pub const fn as_u32(self) -> u32 {
    192         match self {
    193             Self::Article => KIND_ARTICLE,
    194             Self::ClassifiedListing => KIND_CLASSIFIED_LISTING,
    195             Self::CalendarDateEvent => KIND_CALENDAR_DATE_EVENT,
    196             Self::CalendarTimeEvent => KIND_CALENDAR_TIME_EVENT,
    197         }
    198     }
    199 
    200     pub const fn parse(kind: u32) -> Result<Self, Nip22CommentError> {
    201         match kind {
    202             KIND_ARTICLE => Ok(Self::Article),
    203             KIND_CLASSIFIED_LISTING => Ok(Self::ClassifiedListing),
    204             KIND_CALENDAR_DATE_EVENT => Ok(Self::CalendarDateEvent),
    205             KIND_CALENDAR_TIME_EVENT => Ok(Self::CalendarTimeEvent),
    206             actual => Err(Nip22CommentError::RootKindUnsupported { actual }),
    207         }
    208     }
    209 }
    210 
    211 impl From<Nip22CommentRootKind> for u32 {
    212     fn from(value: Nip22CommentRootKind) -> Self {
    213         value.as_u32()
    214     }
    215 }
    216 
    217 impl TryFrom<u32> for Nip22CommentRootKind {
    218     type Error = Nip22CommentError;
    219 
    220     fn try_from(value: u32) -> Result<Self, Self::Error> {
    221         Self::parse(value)
    222     }
    223 }
    224 
    225 /// One revision-specific NIP-22 event root.
    226 ///
    227 /// The caller asserts the target kind and author. This does not prove event
    228 /// existence, signature validity, actual authorship, or relay availability.
    229 #[derive(Clone, Debug, PartialEq, Eq)]
    230 pub struct Nip22EventRootReference {
    231     event_id: EventId,
    232     author: PublicKey,
    233     kind: Nip22CommentRootKind,
    234     relay: Option<NostrRelayHint>,
    235 }
    236 
    237 impl Nip22EventRootReference {
    238     pub fn new(
    239         event_id: EventId,
    240         author: PublicKey,
    241         kind: Nip22CommentRootKind,
    242         relay: Option<NostrRelayHint>,
    243     ) -> Result<Self, Nip22CommentError> {
    244         validate_optional_relay(&relay)?;
    245         Ok(Self {
    246             event_id,
    247             author,
    248             kind,
    249             relay,
    250         })
    251     }
    252 
    253     pub fn parse(
    254         event_id: impl AsRef<str>,
    255         author: impl AsRef<str>,
    256         kind: u32,
    257         relay: Option<&str>,
    258     ) -> Result<Self, Nip22CommentError> {
    259         Self::new(
    260             EventId::parse(event_id).map_err(Nip22CommentError::RootEventIdInvalid)?,
    261             parse_public_key(author).map_err(Nip22CommentError::RootAuthorInvalid)?,
    262             Nip22CommentRootKind::parse(kind)?,
    263             parse_optional_relay(relay)?,
    264         )
    265     }
    266 
    267     pub const fn event_id(&self) -> &EventId {
    268         &self.event_id
    269     }
    270 
    271     pub const fn author(&self) -> &PublicKey {
    272         &self.author
    273     }
    274 
    275     pub const fn kind(&self) -> Nip22CommentRootKind {
    276         self.kind
    277     }
    278 
    279     pub const fn relay(&self) -> Option<&NostrRelayHint> {
    280         self.relay.as_ref()
    281     }
    282 
    283     pub fn relay_or_empty(&self) -> &str {
    284         relay_or_empty(self.relay())
    285     }
    286 }
    287 
    288 /// One coordinate-stable NIP-22 address root.
    289 ///
    290 /// This proves coordinate syntax and the focused root-kind allowlist only. It
    291 /// does not prove event existence, signature validity, a current revision, or
    292 /// relay availability.
    293 #[derive(Clone, Debug, PartialEq, Eq)]
    294 pub struct Nip22AddressRootReference {
    295     coordinate: AddressableCoordinate,
    296     author: PublicKey,
    297     kind: Nip22CommentRootKind,
    298     relay: Option<NostrRelayHint>,
    299 }
    300 
    301 impl Nip22AddressRootReference {
    302     pub fn new(
    303         coordinate: AddressableCoordinate,
    304         relay: Option<NostrRelayHint>,
    305     ) -> Result<Self, Nip22CommentError> {
    306         let parts = AddressableCoordinateParts::parse(coordinate.as_str())
    307             .map_err(Nip22CommentError::RootCoordinateInvalid)?;
    308         let kind = Nip22CommentRootKind::parse(parts.kind)?;
    309         let coordinate = AddressableCoordinate::parse(format!(
    310             "{}:{}:{}",
    311             kind.as_u32(),
    312             parts.pubkey,
    313             parts.d_tag
    314         ))
    315         .map_err(Nip22CommentError::RootCoordinateInvalid)?;
    316         validate_tag_element(coordinate.as_str())?;
    317         validate_optional_relay(&relay)?;
    318         Ok(Self {
    319             coordinate,
    320             author: parts.pubkey,
    321             kind,
    322             relay,
    323         })
    324     }
    325 
    326     pub fn parse(
    327         coordinate: impl AsRef<str>,
    328         relay: Option<&str>,
    329     ) -> Result<Self, Nip22CommentError> {
    330         Self::new(
    331             AddressableCoordinate::parse(coordinate)
    332                 .map_err(Nip22CommentError::RootCoordinateInvalid)?,
    333             parse_optional_relay(relay)?,
    334         )
    335     }
    336 
    337     pub const fn coordinate(&self) -> &AddressableCoordinate {
    338         &self.coordinate
    339     }
    340 
    341     pub const fn author(&self) -> &PublicKey {
    342         &self.author
    343     }
    344 
    345     pub const fn kind(&self) -> Nip22CommentRootKind {
    346         self.kind
    347     }
    348 
    349     pub const fn relay(&self) -> Option<&NostrRelayHint> {
    350         self.relay.as_ref()
    351     }
    352 
    353     pub fn relay_or_empty(&self) -> &str {
    354         relay_or_empty(self.relay())
    355     }
    356 }
    357 
    358 /// One kind-1111 parent reference for a nested NIP-22 comment.
    359 ///
    360 /// The caller asserts the target is a Comment by the stated author. This does
    361 /// not prove event existence, target kind, signature validity, actual
    362 /// authorship, or relay availability.
    363 #[derive(Clone, Debug, PartialEq, Eq)]
    364 pub struct Nip22CommentParentReference {
    365     event_id: EventId,
    366     author: PublicKey,
    367     relay: Option<NostrRelayHint>,
    368 }
    369 
    370 impl Nip22CommentParentReference {
    371     pub fn new(
    372         event_id: EventId,
    373         author: PublicKey,
    374         relay: Option<NostrRelayHint>,
    375     ) -> Result<Self, Nip22CommentError> {
    376         validate_optional_relay(&relay)?;
    377         Ok(Self {
    378             event_id,
    379             author,
    380             relay,
    381         })
    382     }
    383 
    384     pub fn parse(
    385         event_id: impl AsRef<str>,
    386         author: impl AsRef<str>,
    387         relay: Option<&str>,
    388     ) -> Result<Self, Nip22CommentError> {
    389         Self::new(
    390             EventId::parse(event_id).map_err(Nip22CommentError::ParentEventIdInvalid)?,
    391             parse_public_key(author).map_err(Nip22CommentError::ParentAuthorInvalid)?,
    392             parse_optional_relay(relay)?,
    393         )
    394     }
    395 
    396     pub const fn event_id(&self) -> &EventId {
    397         &self.event_id
    398     }
    399 
    400     pub const fn author(&self) -> &PublicKey {
    401         &self.author
    402     }
    403 
    404     pub const fn relay(&self) -> Option<&NostrRelayHint> {
    405         self.relay.as_ref()
    406     }
    407 
    408     pub fn relay_or_empty(&self) -> &str {
    409         relay_or_empty(self.relay())
    410     }
    411 }
    412 
    413 #[derive(Clone, Debug, PartialEq, Eq)]
    414 pub enum Nip22CommentRoot {
    415     Event(Nip22EventRootReference),
    416     Address(Nip22AddressRootReference),
    417 }
    418 
    419 impl Nip22CommentRoot {
    420     pub const fn kind(&self) -> Nip22CommentRootKind {
    421         match self {
    422             Self::Event(reference) => reference.kind(),
    423             Self::Address(reference) => reference.kind(),
    424         }
    425     }
    426 
    427     pub const fn author(&self) -> &PublicKey {
    428         match self {
    429             Self::Event(reference) => reference.author(),
    430             Self::Address(reference) => reference.author(),
    431         }
    432     }
    433 
    434     pub const fn relay(&self) -> Option<&NostrRelayHint> {
    435         match self {
    436             Self::Event(reference) => reference.relay(),
    437             Self::Address(reference) => reference.relay(),
    438         }
    439     }
    440 }
    441 
    442 impl From<Nip22EventRootReference> for Nip22CommentRoot {
    443     fn from(value: Nip22EventRootReference) -> Self {
    444         Self::Event(value)
    445     }
    446 }
    447 
    448 impl From<Nip22AddressRootReference> for Nip22CommentRoot {
    449     fn from(value: Nip22AddressRootReference) -> Self {
    450         Self::Address(value)
    451     }
    452 }
    453 
    454 #[derive(Clone, Debug, PartialEq, Eq)]
    455 pub enum Nip22CommentPosition {
    456     TopLevelEvent,
    457     TopLevelAddress { current_revision: EventId },
    458     Nested { parent: Nip22CommentParentReference },
    459 }
    460 
    461 /// Strict authored kind-1111 NIP-22 comment.
    462 ///
    463 /// This type is opaque and has no Serde construction path.
    464 ///
    465 /// ```compile_fail
    466 /// let _: radroots_event::post::comment::AuthoredNip22Comment =
    467 ///     serde_json::from_str(r#"{"content":"comment"}"#).unwrap();
    468 /// ```
    469 #[derive(Clone, Debug, PartialEq, Eq)]
    470 pub struct AuthoredNip22Comment {
    471     content: String,
    472     root: Nip22CommentRoot,
    473     position: Nip22CommentPosition,
    474 }
    475 
    476 impl AuthoredNip22Comment {
    477     pub fn top_level_event(
    478         content: impl Into<String>,
    479         root: Nip22EventRootReference,
    480     ) -> Result<Self, Nip22CommentError> {
    481         Self::new(
    482             content.into(),
    483             Nip22CommentRoot::Event(root),
    484             Nip22CommentPosition::TopLevelEvent,
    485         )
    486     }
    487 
    488     pub fn top_level_address(
    489         content: impl Into<String>,
    490         root: Nip22AddressRootReference,
    491         current_revision: EventId,
    492     ) -> Result<Self, Nip22CommentError> {
    493         Self::new(
    494             content.into(),
    495             Nip22CommentRoot::Address(root),
    496             Nip22CommentPosition::TopLevelAddress { current_revision },
    497         )
    498     }
    499 
    500     pub fn parse_top_level_address(
    501         content: impl Into<String>,
    502         root: Nip22AddressRootReference,
    503         current_revision: impl AsRef<str>,
    504     ) -> Result<Self, Nip22CommentError> {
    505         Self::top_level_address(
    506             content,
    507             root,
    508             EventId::parse(current_revision).map_err(Nip22CommentError::RevisionEventIdInvalid)?,
    509         )
    510     }
    511 
    512     pub fn nested(
    513         content: impl Into<String>,
    514         root: impl Into<Nip22CommentRoot>,
    515         parent: Nip22CommentParentReference,
    516     ) -> Result<Self, Nip22CommentError> {
    517         let root = root.into();
    518         if matches!(
    519             &root,
    520             Nip22CommentRoot::Event(reference)
    521                 if reference.event_id() == parent.event_id()
    522         ) {
    523             return Err(Nip22CommentError::ParentReferenceMismatch);
    524         }
    525         Self::new(
    526             content.into(),
    527             root,
    528             Nip22CommentPosition::Nested { parent },
    529         )
    530     }
    531 
    532     fn new(
    533         content: String,
    534         root: Nip22CommentRoot,
    535         position: Nip22CommentPosition,
    536     ) -> Result<Self, Nip22CommentError> {
    537         validate_content(&content)?;
    538         validate_authored_comment_wire_size(&content, &root, &position)?;
    539         Ok(Self {
    540             content,
    541             root,
    542             position,
    543         })
    544     }
    545 
    546     pub fn content(&self) -> &str {
    547         &self.content
    548     }
    549 
    550     pub const fn root(&self) -> &Nip22CommentRoot {
    551         &self.root
    552     }
    553 
    554     pub const fn position(&self) -> &Nip22CommentPosition {
    555         &self.position
    556     }
    557 }
    558 
    559 fn parse_optional_relay(relay: Option<&str>) -> Result<Option<NostrRelayHint>, Nip22CommentError> {
    560     match relay {
    561         None | Some("") => Ok(None),
    562         Some(relay) => {
    563             validate_tag_element(relay)?;
    564             NostrRelayHint::parse(relay)
    565                 .map(Some)
    566                 .map_err(Nip22CommentError::RelayInvalid)
    567         }
    568     }
    569 }
    570 
    571 fn validate_optional_relay(relay: &Option<NostrRelayHint>) -> Result<(), Nip22CommentError> {
    572     if let Some(relay) = relay {
    573         validate_tag_element(relay.as_str())?;
    574     }
    575     Ok(())
    576 }
    577 
    578 fn relay_or_empty(relay: Option<&NostrRelayHint>) -> &str {
    579     relay.map_or("", NostrRelayHint::as_str)
    580 }
    581 
    582 fn validate_content(content: &str) -> Result<(), Nip22CommentError> {
    583     crate::require_invariant(!content.trim().is_empty(), &|| {
    584         Nip22CommentError::ContentMissing
    585     })?;
    586     crate::require_invariant(
    587         content.len() <= RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES,
    588         &|| Nip22CommentError::ContentTooLarge {
    589             max: RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES,
    590             actual: content.len(),
    591         },
    592     )
    593 }
    594 
    595 fn validate_tag_element(element: &str) -> Result<(), Nip22CommentError> {
    596     crate::require_invariant(
    597         element.len() <= RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES,
    598         &|| Nip22CommentError::TagElementTooLarge {
    599             max: RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES,
    600             actual: element.len(),
    601         },
    602     )
    603 }
    604 
    605 fn validate_authored_comment_wire_size(
    606     content: &str,
    607     root: &Nip22CommentRoot,
    608     position: &Nip22CommentPosition,
    609 ) -> Result<(), Nip22CommentError> {
    610     let mut tag_bytes = 0usize;
    611     let mut tags_json_bytes = 2usize;
    612     let mut tag_count = 0usize;
    613     let root_kind = root.kind().as_u32().to_string();
    614 
    615     let root_tag_element_count = match root {
    616         Nip22CommentRoot::Event(reference) => {
    617             add_tag(
    618                 &mut tag_bytes,
    619                 &mut tags_json_bytes,
    620                 &mut tag_count,
    621                 &[
    622                     "E",
    623                     reference.event_id().to_hex().as_str(),
    624                     reference.relay_or_empty(),
    625                     reference.author().to_hex().as_str(),
    626                 ],
    627             );
    628             8usize + usize::from(reference.relay().is_some())
    629         }
    630         Nip22CommentRoot::Address(reference) => {
    631             add_optional_relay_tag(
    632                 &mut tag_bytes,
    633                 &mut tags_json_bytes,
    634                 &mut tag_count,
    635                 "A",
    636                 reference.coordinate().as_str(),
    637                 reference.relay(),
    638             );
    639             6usize + 2 * usize::from(reference.relay().is_some())
    640         }
    641     };
    642     add_tag(
    643         &mut tag_bytes,
    644         &mut tags_json_bytes,
    645         &mut tag_count,
    646         &["K", root_kind.as_str()],
    647     );
    648     add_optional_relay_tag(
    649         &mut tag_bytes,
    650         &mut tags_json_bytes,
    651         &mut tag_count,
    652         "P",
    653         root.author().to_hex().as_str(),
    654         root.relay(),
    655     );
    656 
    657     let position_tag_element_count = match (root, position) {
    658         (Nip22CommentRoot::Event(reference), Nip22CommentPosition::TopLevelEvent) => {
    659             add_tag(
    660                 &mut tag_bytes,
    661                 &mut tags_json_bytes,
    662                 &mut tag_count,
    663                 &[
    664                     "e",
    665                     reference.event_id().to_hex().as_str(),
    666                     reference.relay_or_empty(),
    667                     reference.author().to_hex().as_str(),
    668                 ],
    669             );
    670             add_tag(
    671                 &mut tag_bytes,
    672                 &mut tags_json_bytes,
    673                 &mut tag_count,
    674                 &["k", root_kind.as_str()],
    675             );
    676             add_optional_relay_tag(
    677                 &mut tag_bytes,
    678                 &mut tags_json_bytes,
    679                 &mut tag_count,
    680                 "p",
    681                 reference.author().to_hex().as_str(),
    682                 reference.relay(),
    683             );
    684             8usize + usize::from(reference.relay().is_some())
    685         }
    686         (
    687             Nip22CommentRoot::Address(reference),
    688             Nip22CommentPosition::TopLevelAddress { current_revision },
    689         ) => {
    690             add_optional_relay_tag(
    691                 &mut tag_bytes,
    692                 &mut tags_json_bytes,
    693                 &mut tag_count,
    694                 "a",
    695                 reference.coordinate().as_str(),
    696                 reference.relay(),
    697             );
    698             add_optional_relay_tag(
    699                 &mut tag_bytes,
    700                 &mut tags_json_bytes,
    701                 &mut tag_count,
    702                 "e",
    703                 current_revision.to_hex().as_str(),
    704                 reference.relay(),
    705             );
    706             add_tag(
    707                 &mut tag_bytes,
    708                 &mut tags_json_bytes,
    709                 &mut tag_count,
    710                 &["k", root_kind.as_str()],
    711             );
    712             add_optional_relay_tag(
    713                 &mut tag_bytes,
    714                 &mut tags_json_bytes,
    715                 &mut tag_count,
    716                 "p",
    717                 reference.author().to_hex().as_str(),
    718                 reference.relay(),
    719             );
    720             8usize + 3 * usize::from(reference.relay().is_some())
    721         }
    722         (_, Nip22CommentPosition::Nested { parent }) => {
    723             add_tag(
    724                 &mut tag_bytes,
    725                 &mut tags_json_bytes,
    726                 &mut tag_count,
    727                 &[
    728                     "e",
    729                     parent.event_id().to_hex().as_str(),
    730                     parent.relay_or_empty(),
    731                     parent.author().to_hex().as_str(),
    732                 ],
    733             );
    734             add_tag(
    735                 &mut tag_bytes,
    736                 &mut tags_json_bytes,
    737                 &mut tag_count,
    738                 &["k", KIND_COMMENT.to_string().as_str()],
    739             );
    740             add_optional_relay_tag(
    741                 &mut tag_bytes,
    742                 &mut tags_json_bytes,
    743                 &mut tag_count,
    744                 "p",
    745                 parent.author().to_hex().as_str(),
    746                 parent.relay(),
    747             );
    748             8usize + usize::from(parent.relay().is_some())
    749         }
    750         _ => unreachable!("constructors preserve root and position compatibility"),
    751     };
    752 
    753     crate::require_invariant(tag_count <= RADROOTS_NIP22_COMMENT_TAG_MAX_COUNT, &|| {
    754         Nip22CommentError::TagCountExceeded {
    755             max: RADROOTS_NIP22_COMMENT_TAG_MAX_COUNT,
    756             actual: tag_count,
    757         }
    758     })?;
    759     let tag_element_count = root_tag_element_count.saturating_add(position_tag_element_count);
    760     crate::require_invariant(
    761         tag_element_count <= RADROOTS_NIP22_COMMENT_TAG_TOTAL_ELEMENT_MAX_COUNT,
    762         &|| Nip22CommentError::TagElementCountExceeded {
    763             max: RADROOTS_NIP22_COMMENT_TAG_TOTAL_ELEMENT_MAX_COUNT,
    764             actual: tag_element_count,
    765         },
    766     )?;
    767 
    768     crate::require_invariant(
    769         tag_bytes <= RADROOTS_NIP22_COMMENT_TAG_TOTAL_MAX_BYTES,
    770         &|| Nip22CommentError::TagBytesExceeded {
    771             max: RADROOTS_NIP22_COMMENT_TAG_TOTAL_MAX_BYTES,
    772             actual: tag_bytes,
    773         },
    774     )?;
    775     let actual = RADROOTS_NIP22_COMMENT_SIGNED_EVENT_FIXED_MAX_BYTES
    776         .saturating_add(tags_json_bytes)
    777         .saturating_add(canonical_json_string_bytes(content));
    778     crate::require_invariant(
    779         actual <= RADROOTS_NIP22_COMMENT_EVENT_WIRE_MAX_BYTES,
    780         &|| Nip22CommentError::EventWireTooLarge {
    781             max: RADROOTS_NIP22_COMMENT_EVENT_WIRE_MAX_BYTES,
    782             actual,
    783         },
    784     )
    785 }
    786 
    787 fn add_optional_relay_tag(
    788     tag_bytes: &mut usize,
    789     tags_json_bytes: &mut usize,
    790     tag_count: &mut usize,
    791     name: &str,
    792     value: &str,
    793     relay: Option<&NostrRelayHint>,
    794 ) {
    795     if let Some(relay) = relay {
    796         add_tag(
    797             tag_bytes,
    798             tags_json_bytes,
    799             tag_count,
    800             &[name, value, relay.as_str()],
    801         );
    802     } else {
    803         add_tag(tag_bytes, tags_json_bytes, tag_count, &[name, value]);
    804     }
    805 }
    806 
    807 fn add_tag(
    808     tag_bytes: &mut usize,
    809     tags_json_bytes: &mut usize,
    810     tag_count: &mut usize,
    811     elements: &[&str],
    812 ) {
    813     if *tag_count > 0 {
    814         *tags_json_bytes = tags_json_bytes.saturating_add(1);
    815     }
    816     *tags_json_bytes = tags_json_bytes.saturating_add(2);
    817     for (index, element) in elements.iter().enumerate() {
    818         if index > 0 {
    819             *tags_json_bytes = tags_json_bytes.saturating_add(1);
    820         }
    821         *tags_json_bytes = tags_json_bytes.saturating_add(canonical_json_string_bytes(element));
    822         *tag_bytes = tag_bytes.saturating_add(element.len());
    823     }
    824     *tag_count = tag_count.saturating_add(1);
    825 }
    826 
    827 fn canonical_json_string_bytes(value: &str) -> usize {
    828     value.chars().fold(2usize, |total, character| {
    829         total.saturating_add(match character {
    830             '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2,
    831             '\u{0000}'..='\u{001f}' => 6,
    832             _ => character.len_utf8(),
    833         })
    834     })
    835 }
    836 
    837 #[cfg(test)]
    838 #[cfg_attr(coverage_nightly, coverage(off))]
    839 mod tests {
    840     use super::*;
    841 
    842     fn event_root(kind: u32) -> Nip22EventRootReference {
    843         Nip22EventRootReference::parse(
    844             "a".repeat(64),
    845             crate::test_valid_hex_64('b'),
    846             kind,
    847             Some("wss://relay.example"),
    848         )
    849         .expect("event root")
    850     }
    851 
    852     fn address_root(kind: u32) -> Nip22AddressRootReference {
    853         Nip22AddressRootReference::parse(
    854             format!("{kind}:{}:victoria-market", crate::test_valid_hex_64('b')),
    855             Some("wss://relay.example"),
    856         )
    857         .expect("address root")
    858     }
    859 
    860     fn parent() -> Nip22CommentParentReference {
    861         Nip22CommentParentReference::parse(
    862             "c".repeat(64),
    863             "d".repeat(64),
    864             Some("wss://comments.example"),
    865         )
    866         .expect("parent")
    867     }
    868 
    869     #[test]
    870     fn supports_each_root_kind_for_event_and_address_roots() {
    871         for kind in [
    872             KIND_ARTICLE,
    873             KIND_CLASSIFIED_LISTING,
    874             KIND_CALENDAR_DATE_EVENT,
    875             KIND_CALENDAR_TIME_EVENT,
    876         ] {
    877             AuthoredNip22Comment::top_level_event("Comment", event_root(kind))
    878                 .expect("event comment");
    879             AuthoredNip22Comment::parse_top_level_address(
    880                 "Comment",
    881                 address_root(kind),
    882                 "e".repeat(64),
    883             )
    884             .expect("address comment");
    885             AuthoredNip22Comment::nested("Reply", event_root(kind), parent())
    886                 .expect("nested event comment");
    887             AuthoredNip22Comment::nested("Reply", address_root(kind), parent())
    888                 .expect("nested address comment");
    889         }
    890     }
    891 
    892     #[test]
    893     fn rejects_other_root_kinds_and_ambiguous_parent() {
    894         for kind in [1, KIND_COMMENT, 30_018] {
    895             assert!(matches!(
    896                 Nip22EventRootReference::parse(
    897                     "a".repeat(64),
    898                     crate::test_valid_hex_64('b'),
    899                     kind,
    900                     None
    901                 ),
    902                 Err(Nip22CommentError::RootKindUnsupported { actual }) if actual == kind
    903             ));
    904         }
    905 
    906         let root = event_root(KIND_CLASSIFIED_LISTING);
    907         let parent =
    908             Nip22CommentParentReference::parse(root.event_id().to_hex(), "d".repeat(64), None)
    909                 .expect("parent");
    910         assert_eq!(
    911             AuthoredNip22Comment::nested("Reply", root, parent).unwrap_err(),
    912             Nip22CommentError::ParentReferenceMismatch
    913         );
    914     }
    915 
    916     #[test]
    917     fn canonicalizes_address_author_and_requires_current_revision() {
    918         let root = Nip22AddressRootReference::parse(
    919             format!("30402:{}:listing", crate::test_valid_hex_64('B')),
    920             None,
    921         )
    922         .expect("address root");
    923         assert_eq!(
    924             root.coordinate().as_str(),
    925             format!("30402:{}:listing", crate::test_valid_hex_64('b'))
    926         );
    927         assert_eq!(
    928             root.author().to_hex().as_str(),
    929             crate::test_valid_hex_64('b')
    930         );
    931 
    932         let comment =
    933             AuthoredNip22Comment::parse_top_level_address("Comment", root, "E".repeat(64))
    934                 .expect("top-level address");
    935         assert!(matches!(
    936             comment.position(),
    937             Nip22CommentPosition::TopLevelAddress { current_revision }
    938                 if current_revision.to_hex() == "e".repeat(64)
    939         ));
    940     }
    941 
    942     #[test]
    943     fn address_root_rechecks_the_canonical_coordinate_tag_element() {
    944         let maximum_d_tag = "x".repeat(512);
    945         let root = Nip22AddressRootReference::parse(
    946             format!("30402:{}:{maximum_d_tag}", crate::test_valid_hex_64('b')),
    947             None,
    948         )
    949         .expect("maximum public d tag fits the tag-element budget");
    950         assert!(root.coordinate().as_str().len() <= RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES);
    951 
    952         let oversized = format!(
    953             "30402:{}:{}",
    954             crate::test_valid_hex_64('b'),
    955             "x".repeat(513)
    956         );
    957         assert!(matches!(
    958             Nip22AddressRootReference::parse(oversized, None),
    959             Err(Nip22CommentError::RootCoordinateInvalid(
    960                 ParseError::TooLong {
    961                     max: 512,
    962                     actual: 513
    963                 }
    964             ))
    965         ));
    966     }
    967 
    968     #[test]
    969     fn relay_element_budget_precedes_relay_syntax() {
    970         let oversized_noncanonical_relay = format!("WSS://relay.example/{}", "x".repeat(4096));
    971         assert!(matches!(
    972             Nip22EventRootReference::parse(
    973                 "a".repeat(64),
    974                 crate::test_valid_hex_64('b'),
    975                 KIND_CLASSIFIED_LISTING,
    976                 Some(&oversized_noncanonical_relay),
    977             ),
    978             Err(Nip22CommentError::TagElementTooLarge {
    979                 max: RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES,
    980                 actual,
    981             }) if actual == oversized_noncanonical_relay.len()
    982         ));
    983     }
    984 
    985     #[test]
    986     fn enforces_content_and_relay_element_boundaries() {
    987         AuthoredNip22Comment::top_level_event(
    988             "x".repeat(RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES),
    989             event_root(KIND_CLASSIFIED_LISTING),
    990         )
    991         .expect("exact content limit");
    992         assert!(matches!(
    993             AuthoredNip22Comment::top_level_event(
    994                 "x".repeat(RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES + 1),
    995                 event_root(KIND_CLASSIFIED_LISTING)
    996             ),
    997             Err(Nip22CommentError::ContentTooLarge { max, actual })
    998                 if max == RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES
    999                     && actual == max + 1
   1000         ));
   1001 
   1002         let prefix = "wss://relay.example/";
   1003         let exact_relay = format!(
   1004             "{prefix}{}",
   1005             "a".repeat(RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES - prefix.len())
   1006         );
   1007         Nip22EventRootReference::parse(
   1008             "a".repeat(64),
   1009             crate::test_valid_hex_64('b'),
   1010             KIND_CLASSIFIED_LISTING,
   1011             Some(&exact_relay),
   1012         )
   1013         .expect("exact tag-element limit");
   1014         let overflow = format!("{exact_relay}a");
   1015         assert!(matches!(
   1016             Nip22EventRootReference::parse(
   1017                 "a".repeat(64),
   1018                 crate::test_valid_hex_64('b'),
   1019                 KIND_CLASSIFIED_LISTING,
   1020                 Some(&overflow)
   1021             ),
   1022             Err(Nip22CommentError::TagElementTooLarge { max, actual })
   1023                 if max == RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES
   1024                     && actual == max + 1
   1025         ));
   1026     }
   1027 
   1028     #[test]
   1029     fn uses_unicode_white_space_for_blank_content() {
   1030         AuthoredNip22Comment::top_level_event("\u{001c}", event_root(KIND_CLASSIFIED_LISTING))
   1031             .expect("U+001C is not Unicode White_Space");
   1032         assert_eq!(
   1033             AuthoredNip22Comment::top_level_event("\u{00a0}", event_root(KIND_CLASSIFIED_LISTING))
   1034                 .unwrap_err(),
   1035             Nip22CommentError::ContentMissing
   1036         );
   1037     }
   1038 
   1039     #[test]
   1040     fn escaped_content_cannot_cross_compact_signed_wire_limit() {
   1041         let mut lower = 1usize;
   1042         let mut upper = RADROOTS_NIP22_COMMENT_CONTENT_MAX_BYTES;
   1043         while lower < upper {
   1044             let candidate = lower + (upper - lower).div_ceil(2);
   1045             if AuthoredNip22Comment::top_level_event(
   1046                 "\u{0001}".repeat(candidate),
   1047                 event_root(KIND_CLASSIFIED_LISTING),
   1048             )
   1049             .is_ok()
   1050             {
   1051                 lower = candidate;
   1052             } else {
   1053                 upper = candidate - 1;
   1054             }
   1055         }
   1056 
   1057         AuthoredNip22Comment::top_level_event(
   1058             "\u{0001}".repeat(lower),
   1059             event_root(KIND_CLASSIFIED_LISTING),
   1060         )
   1061         .expect("largest escaped content fitting the wire budget");
   1062         assert!(matches!(
   1063             AuthoredNip22Comment::top_level_event(
   1064                 "\u{0001}".repeat(lower + 1),
   1065                 event_root(KIND_CLASSIFIED_LISTING)
   1066             ),
   1067             Err(Nip22CommentError::EventWireTooLarge { max, .. })
   1068                 if max == RADROOTS_NIP22_COMMENT_EVENT_WIRE_MAX_BYTES
   1069         ));
   1070     }
   1071 }