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;