lib

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

registry_v7.rs (26673B)


      1 //! Frozen NIP-10 reply semantics for event-contract registry v7.
      2 
      3 #[cfg(not(feature = "std"))]
      4 use alloc::{collections::BTreeSet, string::String, vec::Vec};
      5 use core::fmt;
      6 #[cfg(feature = "std")]
      7 use std::collections::BTreeSet;
      8 
      9 use radroots_event::{
     10     envelope::kind::KIND_POST,
     11     id::{EventId, ParseError},
     12     post::{
     13         RADROOTS_POST_CONTENT_MAX_BYTES, RADROOTS_POST_EVENT_WIRE_MAX_BYTES,
     14         RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, RADROOTS_POST_TAG_TOTAL_MAX_BYTES,
     15     },
     16     tag::relay_hint::NostrRelayHint,
     17 };
     18 use radroots_identity::{Error as PublicKeyError, PublicKey};
     19 
     20 use crate::verification::v1::RadrootsSignatureVerifiedEvent;
     21 
     22 const RADROOTS_NIP10_REPLY_SIGNED_EVENT_FIXED_BYTES: usize = "{\"id\":\"".len()
     23     + 64
     24     + "\",\"pubkey\":\"".len()
     25     + 64
     26     + "\",\"created_at\":".len()
     27     + ",\"kind\":1,\"tags\":".len()
     28     + ",\"content\":".len()
     29     + ",\"sig\":\"".len()
     30     + 128
     31     + "\"}".len();
     32 
     33 #[non_exhaustive]
     34 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     35 pub enum RadrootsNip10ReplyStyle {
     36     Marked,
     37     LegacyPositional,
     38 }
     39 
     40 /// One ignored interoperability issue encountered while projecting a verified
     41 /// inbound NIP-10 Reply.
     42 ///
     43 /// Diagnostics are ordered deterministically: event-reference diagnostics in
     44 /// `e`-tag order, participant diagnostics in `p`-tag order, then reference to
     45 /// participant relationship diagnostics in root/parent/citation order.
     46 #[non_exhaustive]
     47 #[derive(Clone, Debug, PartialEq, Eq)]
     48 pub enum RadrootsNip10ReplyDiagnostic {
     49     ReplyAuthorMissing,
     50     ReferenceRelayIgnored {
     51         tag_index: usize,
     52         raw_tag: Vec<String>,
     53         error: ParseError,
     54     },
     55     ReferenceAuthorIgnored {
     56         tag_index: usize,
     57         raw_tag: Vec<String>,
     58         error: PublicKeyError,
     59     },
     60     CitationShapeIgnored {
     61         tag_index: usize,
     62         raw_tag: Vec<String>,
     63     },
     64     CitationEventIdIgnored {
     65         tag_index: usize,
     66         raw_tag: Vec<String>,
     67         error: ParseError,
     68     },
     69     CitationMarkerIgnored {
     70         tag_index: usize,
     71         raw_tag: Vec<String>,
     72     },
     73     ReplyAuthorShapeIgnored {
     74         tag_index: usize,
     75         raw_tag: Vec<String>,
     76     },
     77     ReplyAuthorIgnored {
     78         tag_index: usize,
     79         raw_tag: Vec<String>,
     80         error: PublicKeyError,
     81     },
     82     ReplyAuthorRelayIgnored {
     83         tag_index: usize,
     84         raw_tag: Vec<String>,
     85         error: ParseError,
     86     },
     87     ReplyAuthorDuplicateIgnored {
     88         tag_index: usize,
     89         raw_tag: Vec<String>,
     90     },
     91     ReplyAuthorMismatchIgnored {
     92         tag_index: usize,
     93         raw_tag: Vec<String>,
     94     },
     95 }
     96 
     97 impl RadrootsNip10ReplyDiagnostic {
     98     pub const fn code(&self) -> &'static str {
     99         match self {
    100             Self::ReplyAuthorMissing => "reply_author_missing_ignored",
    101             Self::ReferenceRelayIgnored { .. } => "reply_reference_relay_ignored",
    102             Self::ReferenceAuthorIgnored { .. } => "reply_reference_author_ignored",
    103             Self::CitationShapeIgnored { .. } => "reply_citation_shape_ignored",
    104             Self::CitationEventIdIgnored { .. } => "reply_citation_event_id_ignored",
    105             Self::CitationMarkerIgnored { .. } => "reply_citation_marker_ignored",
    106             Self::ReplyAuthorShapeIgnored { .. } => "reply_author_shape_ignored",
    107             Self::ReplyAuthorIgnored { .. } => "reply_author_invalid_ignored",
    108             Self::ReplyAuthorRelayIgnored { .. } => "reply_author_relay_ignored",
    109             Self::ReplyAuthorDuplicateIgnored { .. } => "reply_author_duplicate_ignored",
    110             Self::ReplyAuthorMismatchIgnored { .. } => "reply_author_mismatch_ignored",
    111         }
    112     }
    113 
    114     pub const fn tag_index(&self) -> Option<usize> {
    115         match self {
    116             Self::ReplyAuthorMissing => None,
    117             Self::ReferenceRelayIgnored { tag_index, .. }
    118             | Self::ReferenceAuthorIgnored { tag_index, .. }
    119             | Self::CitationShapeIgnored { tag_index, .. }
    120             | Self::CitationEventIdIgnored { tag_index, .. }
    121             | Self::CitationMarkerIgnored { tag_index, .. }
    122             | Self::ReplyAuthorShapeIgnored { tag_index, .. }
    123             | Self::ReplyAuthorIgnored { tag_index, .. }
    124             | Self::ReplyAuthorRelayIgnored { tag_index, .. }
    125             | Self::ReplyAuthorDuplicateIgnored { tag_index, .. }
    126             | Self::ReplyAuthorMismatchIgnored { tag_index, .. } => Some(*tag_index),
    127         }
    128     }
    129 
    130     pub fn raw_tag(&self) -> Option<&[String]> {
    131         match self {
    132             Self::ReplyAuthorMissing => None,
    133             Self::ReferenceRelayIgnored { raw_tag, .. }
    134             | Self::ReferenceAuthorIgnored { raw_tag, .. }
    135             | Self::CitationShapeIgnored { raw_tag, .. }
    136             | Self::CitationEventIdIgnored { raw_tag, .. }
    137             | Self::CitationMarkerIgnored { raw_tag, .. }
    138             | Self::ReplyAuthorShapeIgnored { raw_tag, .. }
    139             | Self::ReplyAuthorIgnored { raw_tag, .. }
    140             | Self::ReplyAuthorRelayIgnored { raw_tag, .. }
    141             | Self::ReplyAuthorDuplicateIgnored { raw_tag, .. }
    142             | Self::ReplyAuthorMismatchIgnored { raw_tag, .. } => Some(raw_tag),
    143         }
    144     }
    145 }
    146 
    147 #[derive(Clone, Debug, PartialEq, Eq)]
    148 pub struct RadrootsInboundNip10EventReference {
    149     tag_index: usize,
    150     raw_tag: Vec<String>,
    151     event_id: EventId,
    152     relay: Option<NostrRelayHint>,
    153     author_hint: Option<PublicKey>,
    154 }
    155 
    156 impl RadrootsInboundNip10EventReference {
    157     pub const fn tag_index(&self) -> usize {
    158         self.tag_index
    159     }
    160 
    161     pub fn raw_tag(&self) -> &[String] {
    162         &self.raw_tag
    163     }
    164 
    165     pub const fn event_id(&self) -> &EventId {
    166         &self.event_id
    167     }
    168 
    169     pub const fn relay(&self) -> Option<&NostrRelayHint> {
    170         self.relay.as_ref()
    171     }
    172 
    173     pub const fn author_hint(&self) -> Option<&PublicKey> {
    174         self.author_hint.as_ref()
    175     }
    176 }
    177 
    178 #[derive(Clone, Debug, PartialEq, Eq)]
    179 pub struct RadrootsInboundNip10Participant {
    180     tag_index: usize,
    181     raw_tag: Vec<String>,
    182     pubkey: PublicKey,
    183     relay: Option<NostrRelayHint>,
    184 }
    185 
    186 impl RadrootsInboundNip10Participant {
    187     pub const fn tag_index(&self) -> usize {
    188         self.tag_index
    189     }
    190 
    191     pub fn raw_tag(&self) -> &[String] {
    192         &self.raw_tag
    193     }
    194 
    195     pub const fn pubkey(&self) -> &PublicKey {
    196         &self.pubkey
    197     }
    198 
    199     pub const fn relay(&self) -> Option<&NostrRelayHint> {
    200         self.relay.as_ref()
    201     }
    202 }
    203 
    204 /// Normalized projection of one structurally valid NIP-10 reply.
    205 #[derive(Clone, Debug, PartialEq, Eq)]
    206 pub struct RadrootsInboundNip10ReplyProjection {
    207     style: RadrootsNip10ReplyStyle,
    208     root: RadrootsInboundNip10EventReference,
    209     parent: Option<RadrootsInboundNip10EventReference>,
    210     citations: Vec<RadrootsInboundNip10EventReference>,
    211     participants: Vec<RadrootsInboundNip10Participant>,
    212     diagnostics: Vec<RadrootsNip10ReplyDiagnostic>,
    213 }
    214 
    215 impl RadrootsInboundNip10ReplyProjection {
    216     pub const fn style(&self) -> RadrootsNip10ReplyStyle {
    217         self.style
    218     }
    219 
    220     pub const fn root(&self) -> &RadrootsInboundNip10EventReference {
    221         &self.root
    222     }
    223 
    224     pub fn parent(&self) -> &RadrootsInboundNip10EventReference {
    225         self.parent.as_ref().unwrap_or(&self.root)
    226     }
    227 
    228     pub const fn reply_reference(&self) -> Option<&RadrootsInboundNip10EventReference> {
    229         self.parent.as_ref()
    230     }
    231 
    232     pub const fn is_direct(&self) -> bool {
    233         self.parent.is_none()
    234     }
    235 
    236     pub fn citations(&self) -> &[RadrootsInboundNip10EventReference] {
    237         &self.citations
    238     }
    239 
    240     pub fn participants(&self) -> &[RadrootsInboundNip10Participant] {
    241         &self.participants
    242     }
    243 
    244     pub fn diagnostics(&self) -> &[RadrootsNip10ReplyDiagnostic] {
    245         &self.diagnostics
    246     }
    247 
    248     pub const fn contract_id(&self) -> &'static str {
    249         "radroots.social.reply.v1"
    250     }
    251 }
    252 
    253 #[non_exhaustive]
    254 #[derive(Clone, Debug, PartialEq, Eq)]
    255 pub enum RadrootsNip10ReplyProjectionError {
    256     InvalidKind {
    257         expected: u32,
    258         actual: u32,
    259     },
    260     ContentTooLarge {
    261         max: usize,
    262         actual: usize,
    263     },
    264     ReplyMarkerMissing,
    265     ReplyMarkerCount,
    266     EventReferenceShape {
    267         tag_index: usize,
    268     },
    269     EventIdInvalid {
    270         tag_index: usize,
    271         error: ParseError,
    272     },
    273     ReplyReferenceAmbiguous,
    274     TagElementTooLarge {
    275         max: usize,
    276         actual: usize,
    277         tag_index: usize,
    278         element_index: usize,
    279     },
    280     TagBytesExceeded {
    281         max: usize,
    282         actual: usize,
    283     },
    284     EventWireTooLarge {
    285         max: usize,
    286         actual: usize,
    287     },
    288 }
    289 
    290 impl RadrootsNip10ReplyProjectionError {
    291     pub const fn code(&self) -> &'static str {
    292         match self {
    293             Self::InvalidKind { .. } => "invalid_kind",
    294             Self::ContentTooLarge { .. } => "reply_content_too_large",
    295             Self::ReplyMarkerMissing => "reply_marker_missing",
    296             Self::ReplyMarkerCount => "reply_marker_count",
    297             Self::EventReferenceShape { .. } => "reply_reference_shape",
    298             Self::EventIdInvalid { .. } => "reply_event_id_invalid",
    299             Self::ReplyReferenceAmbiguous => "reply_reference_ambiguous",
    300             Self::TagElementTooLarge { .. } => "reply_tag_element_too_large",
    301             Self::TagBytesExceeded { .. } => "reply_tag_bytes_exceeded",
    302             Self::EventWireTooLarge { .. } => "reply_event_wire_too_large",
    303         }
    304     }
    305 }
    306 
    307 impl fmt::Display for RadrootsNip10ReplyProjectionError {
    308     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    309         match self {
    310             Self::InvalidKind { expected, actual } => {
    311                 write!(
    312                     formatter,
    313                     "NIP-10 reply kind must be {expected}, got {actual}"
    314                 )
    315             }
    316             Self::ContentTooLarge { max, actual } => {
    317                 write!(
    318                     formatter,
    319                     "NIP-10 reply content is {actual} bytes; max is {max}"
    320                 )
    321             }
    322             Self::ReplyMarkerMissing => formatter.write_str(
    323                 "NIP-10 reply references require a marked root or valid positional anchors",
    324             ),
    325             Self::ReplyMarkerCount => formatter
    326                 .write_str("NIP-10 reply must contain one root and at most one reply marker"),
    327             Self::EventReferenceShape { tag_index } => {
    328                 write!(formatter, "NIP-10 e tag {tag_index} has an invalid shape")
    329             }
    330             Self::EventIdInvalid { tag_index, error } => {
    331                 write!(
    332                     formatter,
    333                     "NIP-10 e tag {tag_index} has an invalid event id: {error}"
    334                 )
    335             }
    336             Self::ReplyReferenceAmbiguous => {
    337                 formatter.write_str("NIP-10 root and reply references must differ")
    338             }
    339             Self::TagElementTooLarge {
    340                 max,
    341                 actual,
    342                 tag_index,
    343                 element_index,
    344             } => write!(
    345                 formatter,
    346                 "NIP-10 tag {tag_index} element {element_index} is {actual} bytes; max is {max}"
    347             ),
    348             Self::TagBytesExceeded { max, actual } => {
    349                 write!(
    350                     formatter,
    351                     "NIP-10 reply tag bytes are {actual}; max is {max}"
    352                 )
    353             }
    354             Self::EventWireTooLarge { max, actual } => write!(
    355                 formatter,
    356                 "NIP-10 reply compact signed event is {actual} bytes; max is {max}"
    357             ),
    358         }
    359     }
    360 }
    361 
    362 #[cfg(feature = "std")]
    363 impl std::error::Error for RadrootsNip10ReplyProjectionError {}
    364 
    365 /// Projects a signature-and-id verified kind-1 event as a NIP-10 reply.
    366 ///
    367 /// Preferred marked references and deprecated positional references are both
    368 /// accepted. This does not prove that referenced events exist or are kind 1.
    369 pub fn project_verified_nip10_reply_event(
    370     verified_event: &RadrootsSignatureVerifiedEvent,
    371 ) -> Result<RadrootsInboundNip10ReplyProjection, RadrootsNip10ReplyProjectionError> {
    372     project_verified_nip10_reply_event_registry_v7(verified_event)
    373 }
    374 
    375 /// Projects a verified NIP-10 reply with contract-registry-v7 semantics.
    376 pub fn project_verified_nip10_reply_event_registry_v7(
    377     verified_event: &RadrootsSignatureVerifiedEvent,
    378 ) -> Result<RadrootsInboundNip10ReplyProjection, RadrootsNip10ReplyProjectionError> {
    379     let event = verified_event.event();
    380     project_nip10_reply_parts(
    381         event.kind_u32(),
    382         &event.tags_as_vec(),
    383         event.content(),
    384         decimal_digits(event.created_at_u64()),
    385     )
    386 }
    387 
    388 pub(crate) fn project_nip10_reply_parts(
    389     kind: u32,
    390     tags: &[Vec<String>],
    391     content: &str,
    392     created_at_digits: usize,
    393 ) -> Result<RadrootsInboundNip10ReplyProjection, RadrootsNip10ReplyProjectionError> {
    394     if kind != KIND_POST {
    395         return Err(RadrootsNip10ReplyProjectionError::InvalidKind {
    396             expected: KIND_POST,
    397             actual: kind,
    398         });
    399     }
    400     if content.len() > RADROOTS_POST_CONTENT_MAX_BYTES {
    401         return Err(RadrootsNip10ReplyProjectionError::ContentTooLarge {
    402             max: RADROOTS_POST_CONTENT_MAX_BYTES,
    403             actual: content.len(),
    404         });
    405     }
    406     validate_tag_and_wire_budgets(tags, content, created_at_digits)?;
    407 
    408     let e_tags = tags
    409         .iter()
    410         .enumerate()
    411         .filter(|(_, tag)| tag.first().is_some_and(|name| name == "e"))
    412         .collect::<Vec<_>>();
    413     if e_tags.is_empty() {
    414         return Err(RadrootsNip10ReplyProjectionError::ReplyMarkerMissing);
    415     }
    416     let has_marked_reference = e_tags
    417         .iter()
    418         .any(|(_, tag)| matches!(tag.get(3).map(String::as_str), Some("root" | "reply")));
    419     let mut diagnostics = Vec::new();
    420     let (style, root, parent, citations) = if has_marked_reference {
    421         project_marked_references(&e_tags, &mut diagnostics)?
    422     } else {
    423         project_positional_references(&e_tags, &mut diagnostics)?
    424     };
    425     let participants = project_participants(tags, &mut diagnostics);
    426     for reference in core::iter::once(&root)
    427         .chain(parent.iter())
    428         .chain(citations.iter())
    429     {
    430         if let Some(author) = reference.author_hint()
    431             && !participants
    432                 .iter()
    433                 .any(|participant| participant.pubkey() == author)
    434         {
    435             diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorMismatchIgnored {
    436                 tag_index: reference.tag_index,
    437                 raw_tag: reference.raw_tag.clone(),
    438             });
    439         }
    440     }
    441 
    442     Ok(RadrootsInboundNip10ReplyProjection {
    443         style,
    444         root,
    445         parent,
    446         citations,
    447         participants,
    448         diagnostics,
    449     })
    450 }
    451 
    452 type IndexedTag<'a> = (usize, &'a Vec<String>);
    453 
    454 fn project_marked_references(
    455     e_tags: &[IndexedTag<'_>],
    456     diagnostics: &mut Vec<RadrootsNip10ReplyDiagnostic>,
    457 ) -> Result<
    458     (
    459         RadrootsNip10ReplyStyle,
    460         RadrootsInboundNip10EventReference,
    461         Option<RadrootsInboundNip10EventReference>,
    462         Vec<RadrootsInboundNip10EventReference>,
    463     ),
    464     RadrootsNip10ReplyProjectionError,
    465 > {
    466     let mut root = None;
    467     let mut parent = None;
    468     let mut citations = Vec::new();
    469     for (tag_index, tag) in e_tags {
    470         let marker = match tag.get(3).map(String::as_str) {
    471             Some(marker @ ("root" | "reply")) => marker,
    472             _ => {
    473                 if let Some(citation) = project_supplemental_reference(*tag_index, tag, diagnostics)
    474                 {
    475                     citations.push(citation);
    476                 }
    477                 continue;
    478             }
    479         };
    480         if !matches!(tag.len(), 4 | 5) {
    481             return Err(RadrootsNip10ReplyProjectionError::EventReferenceShape {
    482                 tag_index: *tag_index,
    483             });
    484         }
    485         let reference = parse_event_reference(*tag_index, tag, diagnostics)?;
    486         match marker {
    487             "root" if root.is_none() => root = Some(reference),
    488             "reply" if parent.is_none() => parent = Some(reference),
    489             _ => return Err(RadrootsNip10ReplyProjectionError::ReplyMarkerCount),
    490         }
    491     }
    492     let Some(root) = root else {
    493         return Err(RadrootsNip10ReplyProjectionError::ReplyMarkerCount);
    494     };
    495     if parent
    496         .as_ref()
    497         .is_some_and(|parent| parent.event_id == root.event_id)
    498     {
    499         return Err(RadrootsNip10ReplyProjectionError::ReplyReferenceAmbiguous);
    500     }
    501     Ok((RadrootsNip10ReplyStyle::Marked, root, parent, citations))
    502 }
    503 
    504 fn project_positional_references(
    505     e_tags: &[IndexedTag<'_>],
    506     diagnostics: &mut Vec<RadrootsNip10ReplyDiagnostic>,
    507 ) -> Result<
    508     (
    509         RadrootsNip10ReplyStyle,
    510         RadrootsInboundNip10EventReference,
    511         Option<RadrootsInboundNip10EventReference>,
    512         Vec<RadrootsInboundNip10EventReference>,
    513     ),
    514     RadrootsNip10ReplyProjectionError,
    515 > {
    516     let (root_tag_index, root_tag) = e_tags[0];
    517     if !is_positional_reference_shape(root_tag) {
    518         return Err(RadrootsNip10ReplyProjectionError::ReplyMarkerMissing);
    519     }
    520     let root = parse_event_reference(root_tag_index, root_tag, diagnostics)?;
    521     let mut citations = Vec::new();
    522     for (tag_index, tag) in e_tags.iter().skip(1).take(e_tags.len().saturating_sub(2)) {
    523         if let Some(citation) = project_supplemental_reference(*tag_index, tag, diagnostics) {
    524             citations.push(citation);
    525         }
    526     }
    527     let parent = if e_tags.len() > 1 {
    528         let (parent_tag_index, parent_tag) = e_tags[e_tags.len() - 1];
    529         if !is_positional_reference_shape(parent_tag) {
    530             return Err(RadrootsNip10ReplyProjectionError::ReplyMarkerMissing);
    531         }
    532         Some(parse_event_reference(
    533             parent_tag_index,
    534             parent_tag,
    535             diagnostics,
    536         )?)
    537     } else {
    538         None
    539     };
    540     if parent
    541         .as_ref()
    542         .is_some_and(|parent| parent.event_id == root.event_id)
    543     {
    544         return Err(RadrootsNip10ReplyProjectionError::ReplyReferenceAmbiguous);
    545     }
    546     Ok((
    547         RadrootsNip10ReplyStyle::LegacyPositional,
    548         root,
    549         parent,
    550         citations,
    551     ))
    552 }
    553 
    554 fn parse_event_reference(
    555     tag_index: usize,
    556     tag: &[String],
    557     diagnostics: &mut Vec<RadrootsNip10ReplyDiagnostic>,
    558 ) -> Result<RadrootsInboundNip10EventReference, RadrootsNip10ReplyProjectionError> {
    559     let event_id = tag
    560         .get(1)
    561         .ok_or(RadrootsNip10ReplyProjectionError::EventReferenceShape { tag_index })
    562         .and_then(|value| {
    563             EventId::parse(value).map_err(|error| {
    564                 RadrootsNip10ReplyProjectionError::EventIdInvalid { tag_index, error }
    565             })
    566         })?;
    567     let relay = match parse_relay_hint(tag.get(2).map(String::as_str)) {
    568         Ok(relay) => relay,
    569         Err(error) => {
    570             diagnostics.push(RadrootsNip10ReplyDiagnostic::ReferenceRelayIgnored {
    571                 tag_index,
    572                 raw_tag: tag.to_vec(),
    573                 error,
    574             });
    575             None
    576         }
    577     };
    578     let author_hint = if tag.len() == 5 {
    579         match PublicKey::from_hex(&tag[4]) {
    580             Ok(author) => Some(author),
    581             Err(error) => {
    582                 diagnostics.push(RadrootsNip10ReplyDiagnostic::ReferenceAuthorIgnored {
    583                     tag_index,
    584                     raw_tag: tag.to_vec(),
    585                     error,
    586                 });
    587                 None
    588             }
    589         }
    590     } else {
    591         None
    592     };
    593     Ok(RadrootsInboundNip10EventReference {
    594         tag_index,
    595         raw_tag: tag.to_vec(),
    596         event_id,
    597         relay,
    598         author_hint,
    599     })
    600 }
    601 
    602 fn project_supplemental_reference(
    603     tag_index: usize,
    604     tag: &[String],
    605     diagnostics: &mut Vec<RadrootsNip10ReplyDiagnostic>,
    606 ) -> Option<RadrootsInboundNip10EventReference> {
    607     match tag.get(3).map(String::as_str) {
    608         Some("") if matches!(tag.len(), 4 | 5) => {}
    609         Some("") => {
    610             diagnostics.push(RadrootsNip10ReplyDiagnostic::CitationShapeIgnored {
    611                 tag_index,
    612                 raw_tag: tag.to_vec(),
    613             });
    614             return None;
    615         }
    616         Some(_) => {
    617             diagnostics.push(RadrootsNip10ReplyDiagnostic::CitationMarkerIgnored {
    618                 tag_index,
    619                 raw_tag: tag.to_vec(),
    620             });
    621             return None;
    622         }
    623         None if matches!(tag.len(), 2 | 3) => {}
    624         None => {
    625             diagnostics.push(RadrootsNip10ReplyDiagnostic::CitationShapeIgnored {
    626                 tag_index,
    627                 raw_tag: tag.to_vec(),
    628             });
    629             return None;
    630         }
    631     }
    632     let event_id = match EventId::parse(&tag[1]) {
    633         Ok(event_id) => event_id,
    634         Err(error) => {
    635             diagnostics.push(RadrootsNip10ReplyDiagnostic::CitationEventIdIgnored {
    636                 tag_index,
    637                 raw_tag: tag.to_vec(),
    638                 error,
    639             });
    640             return None;
    641         }
    642     };
    643     let relay = match parse_relay_hint(tag.get(2).map(String::as_str)) {
    644         Ok(relay) => relay,
    645         Err(error) => {
    646             diagnostics.push(RadrootsNip10ReplyDiagnostic::ReferenceRelayIgnored {
    647                 tag_index,
    648                 raw_tag: tag.to_vec(),
    649                 error,
    650             });
    651             None
    652         }
    653     };
    654     let author_hint = if tag.len() == 5 {
    655         match PublicKey::from_hex(&tag[4]) {
    656             Ok(author) => Some(author),
    657             Err(error) => {
    658                 diagnostics.push(RadrootsNip10ReplyDiagnostic::ReferenceAuthorIgnored {
    659                     tag_index,
    660                     raw_tag: tag.to_vec(),
    661                     error,
    662                 });
    663                 None
    664             }
    665         }
    666     } else {
    667         None
    668     };
    669     Some(RadrootsInboundNip10EventReference {
    670         tag_index,
    671         raw_tag: tag.to_vec(),
    672         event_id,
    673         relay,
    674         author_hint,
    675     })
    676 }
    677 
    678 fn is_positional_reference_shape(tag: &[String]) -> bool {
    679     matches!(tag.len(), 2 | 3)
    680         || matches!(tag.len(), 4 | 5) && tag.get(3).is_some_and(String::is_empty)
    681 }
    682 
    683 fn project_participants(
    684     tags: &[Vec<String>],
    685     diagnostics: &mut Vec<RadrootsNip10ReplyDiagnostic>,
    686 ) -> Vec<RadrootsInboundNip10Participant> {
    687     let p_tags = tags
    688         .iter()
    689         .enumerate()
    690         .filter(|(_, tag)| tag.first().is_some_and(|name| name == "p"))
    691         .collect::<Vec<_>>();
    692     if p_tags.is_empty() {
    693         diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorMissing);
    694         return Vec::new();
    695     }
    696 
    697     let mut seen = BTreeSet::new();
    698     let mut participants = Vec::with_capacity(p_tags.len());
    699     for (tag_index, tag) in p_tags {
    700         if !(2..=4).contains(&tag.len()) {
    701             diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorShapeIgnored {
    702                 tag_index,
    703                 raw_tag: tag.clone(),
    704             });
    705             if tag.len() < 2 {
    706                 continue;
    707             }
    708         }
    709         let pubkey = match PublicKey::from_hex(&tag[1]) {
    710             Ok(pubkey) => pubkey,
    711             Err(error) => {
    712                 diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorIgnored {
    713                     tag_index,
    714                     raw_tag: tag.clone(),
    715                     error,
    716                 });
    717                 continue;
    718             }
    719         };
    720         let relay = match parse_relay_hint(tag.get(2).map(String::as_str)) {
    721             Ok(relay) => relay,
    722             Err(error) => {
    723                 diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorRelayIgnored {
    724                     tag_index,
    725                     raw_tag: tag.clone(),
    726                     error,
    727                 });
    728                 None
    729             }
    730         };
    731         if !seen.insert(pubkey) {
    732             diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorDuplicateIgnored {
    733                 tag_index,
    734                 raw_tag: tag.clone(),
    735             });
    736             continue;
    737         }
    738         participants.push(RadrootsInboundNip10Participant {
    739             tag_index,
    740             raw_tag: tag.clone(),
    741             pubkey,
    742             relay,
    743         });
    744     }
    745     participants
    746 }
    747 
    748 fn parse_relay_hint(value: Option<&str>) -> Result<Option<NostrRelayHint>, ParseError> {
    749     match value {
    750         None | Some("") => Ok(None),
    751         Some(value) => NostrRelayHint::parse(value).map(Some),
    752     }
    753 }
    754 
    755 fn validate_tag_and_wire_budgets(
    756     tags: &[Vec<String>],
    757     content: &str,
    758     created_at_digits: usize,
    759 ) -> Result<(), RadrootsNip10ReplyProjectionError> {
    760     let mut tag_bytes = 0usize;
    761     let mut tags_json_bytes = 2usize;
    762     for (tag_index, tag) in tags.iter().enumerate() {
    763         if tag_index > 0 {
    764             tags_json_bytes = tags_json_bytes.saturating_add(1);
    765         }
    766         tags_json_bytes = tags_json_bytes.saturating_add(2);
    767         for (element_index, element) in tag.iter().enumerate() {
    768             if element.len() > RADROOTS_POST_TAG_ELEMENT_MAX_BYTES {
    769                 return Err(RadrootsNip10ReplyProjectionError::TagElementTooLarge {
    770                     max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES,
    771                     actual: element.len(),
    772                     tag_index,
    773                     element_index,
    774                 });
    775             }
    776             if element_index > 0 {
    777                 tags_json_bytes = tags_json_bytes.saturating_add(1);
    778             }
    779             tags_json_bytes = tags_json_bytes.saturating_add(canonical_json_string_bytes(element));
    780             tag_bytes = tag_bytes.saturating_add(element.len());
    781         }
    782     }
    783     if tag_bytes > RADROOTS_POST_TAG_TOTAL_MAX_BYTES {
    784         return Err(RadrootsNip10ReplyProjectionError::TagBytesExceeded {
    785             max: RADROOTS_POST_TAG_TOTAL_MAX_BYTES,
    786             actual: tag_bytes,
    787         });
    788     }
    789     let actual = RADROOTS_NIP10_REPLY_SIGNED_EVENT_FIXED_BYTES
    790         .saturating_add(created_at_digits)
    791         .saturating_add(tags_json_bytes)
    792         .saturating_add(canonical_json_string_bytes(content));
    793     if actual > RADROOTS_POST_EVENT_WIRE_MAX_BYTES {
    794         return Err(RadrootsNip10ReplyProjectionError::EventWireTooLarge {
    795             max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES,
    796             actual,
    797         });
    798     }
    799     Ok(())
    800 }
    801 
    802 fn decimal_digits(mut value: u64) -> usize {
    803     let mut digits = 1usize;
    804     while value >= 10 {
    805         value /= 10;
    806         digits += 1;
    807     }
    808     digits
    809 }
    810 
    811 fn canonical_json_string_bytes(value: &str) -> usize {
    812     value.chars().fold(2usize, |total, character| {
    813         total.saturating_add(match character {
    814             '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2,
    815             '\u{0000}'..='\u{001f}' => 6,
    816             _ => character.len_utf8(),
    817         })
    818     })
    819 }
    820 
    821 #[cfg(test)]
    822 mod tests;