reconciliation_v1.rs (38078B)
1 #![forbid(unsafe_code)] 2 3 //! Frozen NIP-09 projection, admission, and suppression semantics. 4 5 #[cfg(feature = "json")] 6 pub mod admission { 7 //! Frozen NIP-09 request-admission semantics for reconciliation v1. 8 9 use core::fmt; 10 11 use radroots_event::contract::registry_v7::{EventContract, event_contract_registry_v7}; 12 use radroots_event::envelope::EventEnvelope; 13 14 use crate::{ 15 deletion::reconciliation_v1::inbound::{ 16 RadrootsInboundNip09DeletionProjection, RadrootsNip09DeletionProjectionError, 17 }, 18 verification::v1::{ 19 RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent, verify_nip01_event_v1, 20 }, 21 }; 22 23 /// A signature-and-id verified kind-5 event admitted as a NIP-09 request. 24 /// 25 /// Admission establishes only the request contract. It does not establish that 26 /// any requested deletion effect is authorized or applicable. 27 #[derive(Clone, Debug, PartialEq, Eq)] 28 pub struct RadrootsAdmittedNip09DeletionRequestEventV1 { 29 verified_event: RadrootsSignatureVerifiedEvent, 30 projection: RadrootsInboundNip09DeletionProjection, 31 } 32 33 /// Current compatibility name for the reconciliation-v1 deletion admission. 34 pub type RadrootsAdmittedNip09DeletionRequestEvent = 35 RadrootsAdmittedNip09DeletionRequestEventV1; 36 37 impl RadrootsAdmittedNip09DeletionRequestEventV1 { 38 pub fn verified_event(&self) -> &RadrootsSignatureVerifiedEvent { 39 &self.verified_event 40 } 41 42 pub fn event(&self) -> &EventEnvelope { 43 self.verified_event.event() 44 } 45 46 pub const fn projection(&self) -> &RadrootsInboundNip09DeletionProjection { 47 &self.projection 48 } 49 50 pub fn contract(&self) -> &'static EventContract { 51 event_contract_registry_v7(self.projection.contract_id()) 52 .expect("NIP-09 deletion request contract is registry-owned") 53 } 54 55 pub fn into_parts( 56 self, 57 ) -> ( 58 RadrootsSignatureVerifiedEvent, 59 RadrootsInboundNip09DeletionProjection, 60 ) { 61 (self.verified_event, self.projection) 62 } 63 } 64 65 #[non_exhaustive] 66 #[derive(Clone, Debug, PartialEq, Eq)] 67 pub enum RadrootsNip09DeletionAdmissionError { 68 Nip01Verification(RadrootsNip01VerificationError), 69 Projection(RadrootsNip09DeletionProjectionError), 70 } 71 72 impl RadrootsNip09DeletionAdmissionError { 73 pub const fn code(&self) -> &'static str { 74 match self { 75 Self::Nip01Verification(error) => error.code(), 76 Self::Projection(error) => error.code(), 77 } 78 } 79 } 80 81 impl fmt::Display for RadrootsNip09DeletionAdmissionError { 82 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 83 match self { 84 Self::Nip01Verification(error) => write!(formatter, "{error}"), 85 Self::Projection(error) => write!(formatter, "{error}"), 86 } 87 } 88 } 89 90 #[cfg(feature = "std")] 91 impl std::error::Error for RadrootsNip09DeletionAdmissionError { 92 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 93 match self { 94 Self::Nip01Verification(error) => Some(error), 95 Self::Projection(error) => Some(error), 96 } 97 } 98 } 99 100 impl From<RadrootsNip01VerificationError> for RadrootsNip09DeletionAdmissionError { 101 fn from(value: RadrootsNip01VerificationError) -> Self { 102 Self::Nip01Verification(value) 103 } 104 } 105 106 impl From<RadrootsNip09DeletionProjectionError> for RadrootsNip09DeletionAdmissionError { 107 fn from(value: RadrootsNip09DeletionProjectionError) -> Self { 108 Self::Projection(value) 109 } 110 } 111 112 pub fn admit_verified_nip09_deletion_request_event( 113 verified_event: RadrootsSignatureVerifiedEvent, 114 ) -> Result<RadrootsAdmittedNip09DeletionRequestEvent, RadrootsNip09DeletionAdmissionError> 115 { 116 admit_verified_nip09_deletion_request_event_v1(verified_event) 117 } 118 119 /// Admits a verified NIP-09 request with reconciliation-v1 semantics. 120 pub fn admit_verified_nip09_deletion_request_event_v1( 121 verified_event: RadrootsSignatureVerifiedEvent, 122 ) -> Result<RadrootsAdmittedNip09DeletionRequestEventV1, RadrootsNip09DeletionAdmissionError> 123 { 124 let projection = 125 super::inbound::project_verified_nip09_deletion_request_event_v1(&verified_event)?; 126 Ok(RadrootsAdmittedNip09DeletionRequestEventV1 { 127 verified_event, 128 projection, 129 }) 130 } 131 132 pub fn verify_and_admit_nip09_deletion_request_event( 133 event: EventEnvelope, 134 ) -> Result<RadrootsAdmittedNip09DeletionRequestEvent, RadrootsNip09DeletionAdmissionError> 135 { 136 admit_verified_nip09_deletion_request_event_v1(verify_nip01_event_v1(event)?) 137 } 138 139 #[cfg(test)] 140 mod tests; 141 } 142 143 #[cfg(feature = "json")] 144 pub mod evaluator { 145 //! Frozen NIP-09 suppression semantics for reconciliation v1. 146 147 #[cfg(not(feature = "std"))] 148 use alloc::format; 149 150 use radroots_event::{ 151 envelope::kind::KIND_DELETION_REQUEST, 152 id::{EventId, Nip01Coordinate}, 153 }; 154 155 use crate::verification::v1::RadrootsSignatureVerifiedEvent; 156 157 use super::admission::RadrootsAdmittedNip09DeletionRequestEventV1; 158 159 /// Whether a verified event remains visible after NIP-09 evaluation. 160 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 161 pub enum RadrootsNip09SuppressionOutcome { 162 Visible, 163 Suppressed, 164 } 165 166 impl RadrootsNip09SuppressionOutcome { 167 pub const fn code(self) -> &'static str { 168 match self { 169 Self::Visible => "visible", 170 Self::Suppressed => "suppressed", 171 } 172 } 173 } 174 175 /// The stable explanation for a NIP-09 suppression outcome. 176 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 177 pub enum RadrootsNip09SuppressionReason { 178 DeletionRequestImmune, 179 NoAuthorizedReference, 180 RequestAuthorMismatch, 181 AddressCutoffPrecedesTarget, 182 EventIdReference, 183 AddressReferenceAtOrBeforeCutoff, 184 EventIdAndAddressReference, 185 } 186 187 impl RadrootsNip09SuppressionReason { 188 pub const fn code(self) -> &'static str { 189 match self { 190 Self::DeletionRequestImmune => "deletion_request_immune", 191 Self::NoAuthorizedReference => "deletion_no_authorized_reference", 192 Self::RequestAuthorMismatch => "deletion_request_author_mismatch", 193 Self::AddressCutoffPrecedesTarget => "deletion_address_cutoff_precedes_target", 194 Self::EventIdReference => "deletion_event_id_reference", 195 Self::AddressReferenceAtOrBeforeCutoff => "deletion_address_reference", 196 Self::EventIdAndAddressReference => "deletion_event_id_and_address_reference", 197 } 198 } 199 } 200 201 /// Canonical evidence for an authorized exact event-id reference. 202 #[derive(Clone, Debug, PartialEq, Eq)] 203 pub struct RadrootsNip09EventReferenceEvidence { 204 request_id: EventId, 205 } 206 207 impl RadrootsNip09EventReferenceEvidence { 208 pub const fn request_id(&self) -> &EventId { 209 &self.request_id 210 } 211 } 212 213 /// Canonical evidence for authorized address references. 214 #[derive(Clone, Debug, PartialEq, Eq)] 215 pub struct RadrootsNip09AddressReferenceEvidence { 216 coordinate: Nip01Coordinate, 217 inclusive_cutoff: u64, 218 request_id: EventId, 219 } 220 221 impl RadrootsNip09AddressReferenceEvidence { 222 pub const fn coordinate(&self) -> &Nip01Coordinate { 223 &self.coordinate 224 } 225 226 pub const fn inclusive_cutoff(&self) -> u64 { 227 self.inclusive_cutoff 228 } 229 230 pub const fn request_id(&self) -> &EventId { 231 &self.request_id 232 } 233 } 234 235 /// A pure NIP-09 visibility decision with canonical supporting evidence. 236 #[derive(Clone, Debug, PartialEq, Eq)] 237 pub struct RadrootsNip09SuppressionDecision { 238 outcome: RadrootsNip09SuppressionOutcome, 239 reason: RadrootsNip09SuppressionReason, 240 event_reference: Option<RadrootsNip09EventReferenceEvidence>, 241 address_reference: Option<RadrootsNip09AddressReferenceEvidence>, 242 } 243 244 impl RadrootsNip09SuppressionDecision { 245 pub const fn outcome(&self) -> RadrootsNip09SuppressionOutcome { 246 self.outcome 247 } 248 249 pub const fn reason(&self) -> RadrootsNip09SuppressionReason { 250 self.reason 251 } 252 253 pub const fn event_reference(&self) -> Option<&RadrootsNip09EventReferenceEvidence> { 254 self.event_reference.as_ref() 255 } 256 257 pub const fn address_reference(&self) -> Option<&RadrootsNip09AddressReferenceEvidence> { 258 self.address_reference.as_ref() 259 } 260 } 261 262 /// Evaluates deterministic NIP-09 suppression without mutating stored events. 263 pub fn evaluate_nip09_suppression( 264 target: &RadrootsSignatureVerifiedEvent, 265 requests: &[RadrootsAdmittedNip09DeletionRequestEventV1], 266 ) -> RadrootsNip09SuppressionDecision { 267 evaluate_nip09_suppression_v1(target, requests) 268 } 269 270 /// Evaluates suppression with the semantics frozen for reconciliation v1. 271 pub fn evaluate_nip09_suppression_v1( 272 target: &RadrootsSignatureVerifiedEvent, 273 requests: &[RadrootsAdmittedNip09DeletionRequestEventV1], 274 ) -> RadrootsNip09SuppressionDecision { 275 evaluate_nip09_suppression_from_borrowed_requests_v1(target, requests) 276 } 277 278 /// Evaluates reconciliation-v1 suppression from borrowed deletion requests. 279 /// 280 /// This entry point lets indexed stores evaluate only exact request matches 281 /// without cloning admitted request payloads. Iteration order does not affect 282 /// the canonical evidence reduction. 283 pub fn evaluate_nip09_suppression_from_borrowed_requests_v1<'a>( 284 target: &RadrootsSignatureVerifiedEvent, 285 requests: impl IntoIterator<Item = &'a RadrootsAdmittedNip09DeletionRequestEventV1>, 286 ) -> RadrootsNip09SuppressionDecision { 287 let target_event = target.event(); 288 if target_event.kind_u32() == KIND_DELETION_REQUEST { 289 return decision( 290 RadrootsNip09SuppressionOutcome::Visible, 291 RadrootsNip09SuppressionReason::DeletionRequestImmune, 292 None, 293 None, 294 ); 295 } 296 297 let target_coordinate = nip01_coordinate(target); 298 let mut event_reference = None; 299 let mut address_reference = None; 300 let mut has_unauthorized_reference = false; 301 302 for request in requests { 303 let request_event = request.event(); 304 let projection = request.projection(); 305 let event_matches = projection 306 .event_targets() 307 .iter() 308 .any(|reference| reference.event_id() == target_event.id()); 309 let address_match = target_coordinate.as_ref().filter(|coordinate| { 310 projection 311 .address_targets() 312 .iter() 313 .any(|reference| reference.coordinate() == *coordinate) 314 }); 315 if !event_matches && address_match.is_none() { 316 continue; 317 } 318 if request_event.author() != target_event.author() { 319 has_unauthorized_reference = true; 320 continue; 321 } 322 323 if event_matches 324 && event_reference.as_ref().is_none_or( 325 |current: &RadrootsNip09EventReferenceEvidence| { 326 request_event.id() < current.request_id() 327 }, 328 ) 329 { 330 event_reference = Some(RadrootsNip09EventReferenceEvidence { 331 request_id: *request_event.id(), 332 }); 333 } 334 if let Some(coordinate) = address_match { 335 let inclusive_cutoff = request_event.created_at_u64(); 336 if address_reference.as_ref().is_none_or( 337 |current: &RadrootsNip09AddressReferenceEvidence| { 338 inclusive_cutoff > current.inclusive_cutoff() 339 || (inclusive_cutoff == current.inclusive_cutoff() 340 && request_event.id() < current.request_id()) 341 }, 342 ) { 343 address_reference = Some(RadrootsNip09AddressReferenceEvidence { 344 coordinate: coordinate.clone(), 345 inclusive_cutoff, 346 request_id: *request_event.id(), 347 }); 348 } 349 } 350 } 351 352 let address_applies = address_reference 353 .as_ref() 354 .is_some_and(|reference| target_event.created_at_u64() <= reference.inclusive_cutoff()); 355 let (outcome, reason) = match (event_reference.is_some(), address_applies) { 356 (true, true) => ( 357 RadrootsNip09SuppressionOutcome::Suppressed, 358 RadrootsNip09SuppressionReason::EventIdAndAddressReference, 359 ), 360 (true, false) => ( 361 RadrootsNip09SuppressionOutcome::Suppressed, 362 RadrootsNip09SuppressionReason::EventIdReference, 363 ), 364 (false, true) => ( 365 RadrootsNip09SuppressionOutcome::Suppressed, 366 RadrootsNip09SuppressionReason::AddressReferenceAtOrBeforeCutoff, 367 ), 368 (false, false) if address_reference.is_some() => ( 369 RadrootsNip09SuppressionOutcome::Visible, 370 RadrootsNip09SuppressionReason::AddressCutoffPrecedesTarget, 371 ), 372 (false, false) if has_unauthorized_reference => ( 373 RadrootsNip09SuppressionOutcome::Visible, 374 RadrootsNip09SuppressionReason::RequestAuthorMismatch, 375 ), 376 (false, false) => ( 377 RadrootsNip09SuppressionOutcome::Visible, 378 RadrootsNip09SuppressionReason::NoAuthorizedReference, 379 ), 380 }; 381 382 decision(outcome, reason, event_reference, address_reference) 383 } 384 385 fn nip01_coordinate(target: &RadrootsSignatureVerifiedEvent) -> Option<Nip01Coordinate> { 386 let event = target.event(); 387 let kind = event.kind_u32(); 388 let identifier = if matches!(kind, 0 | 3) || (10_000..=19_999).contains(&kind) { 389 "" 390 } else if (30_000..=39_999).contains(&kind) { 391 event 392 .tag_slices() 393 .iter() 394 .find(|tag| tag.as_slice().first().is_some_and(|name| name == "d"))? 395 .as_slice() 396 .get(1)? 397 .as_str() 398 } else { 399 return None; 400 }; 401 Nip01Coordinate::parse(format!("{kind}:{}:{identifier}", event.author())).ok() 402 } 403 404 const fn decision( 405 outcome: RadrootsNip09SuppressionOutcome, 406 reason: RadrootsNip09SuppressionReason, 407 event_reference: Option<RadrootsNip09EventReferenceEvidence>, 408 address_reference: Option<RadrootsNip09AddressReferenceEvidence>, 409 ) -> RadrootsNip09SuppressionDecision { 410 RadrootsNip09SuppressionDecision { 411 outcome, 412 reason, 413 event_reference, 414 address_reference, 415 } 416 } 417 418 #[cfg(test)] 419 mod tests; 420 } 421 422 pub mod inbound { 423 //! Frozen NIP-09 request-projection semantics for reconciliation v1. 424 425 #[cfg(not(feature = "std"))] 426 use alloc::{ 427 collections::{BTreeMap, BTreeSet}, 428 string::{String, ToString}, 429 vec::Vec, 430 }; 431 use core::fmt; 432 #[cfg(feature = "std")] 433 use std::{ 434 collections::{BTreeMap, BTreeSet}, 435 string::String, 436 vec::Vec, 437 }; 438 439 use radroots_event::{ 440 envelope::kind::KIND_DELETION_REQUEST, 441 id::{EventId, Nip01Coordinate, Nip01CoordinateParseError, ParseError}, 442 post::deletion::{ 443 RADROOTS_NIP09_DELETION_CONTENT_MAX_BYTES, 444 RADROOTS_NIP09_DELETION_EVENT_WIRE_MAX_BYTES, 445 RADROOTS_NIP09_DELETION_TAG_ELEMENT_MAX_BYTES, RADROOTS_NIP09_DELETION_TAG_MAX_COUNT, 446 RADROOTS_NIP09_DELETION_TAG_TOTAL_ELEMENT_MAX_COUNT, 447 RADROOTS_NIP09_DELETION_TAG_TOTAL_MAX_BYTES, RADROOTS_NIP09_DELETION_TARGET_KIND_MAX, 448 }, 449 }; 450 451 use crate::verification::v1::RadrootsSignatureVerifiedEvent; 452 453 const RADROOTS_NIP09_DELETION_SIGNED_EVENT_FIXED_BYTES: usize = "{\"id\":\"".len() 454 + 64 455 + "\",\"pubkey\":\"".len() 456 + 64 457 + "\",\"created_at\":".len() 458 + ",\"kind\":5,\"tags\":".len() 459 + ",\"content\":".len() 460 + ",\"sig\":\"".len() 461 + 128 462 + "\"}".len(); 463 464 #[non_exhaustive] 465 #[derive(Clone, Debug, PartialEq, Eq)] 466 pub enum RadrootsNip09DeletionDiagnostic { 467 KindAdvisoryShapeIgnored { 468 tag_index: usize, 469 raw_tag: Vec<String>, 470 }, 471 KindAdvisoryInvalidIgnored { 472 tag_index: usize, 473 raw_tag: Vec<String>, 474 }, 475 KindAdvisoryDuplicateIgnored { 476 tag_index: usize, 477 raw_tag: Vec<String>, 478 }, 479 KindAdvisoryConflictIgnored { 480 tag_index: usize, 481 raw_tag: Vec<String>, 482 }, 483 } 484 485 impl RadrootsNip09DeletionDiagnostic { 486 pub const fn code(&self) -> &'static str { 487 match self { 488 Self::KindAdvisoryShapeIgnored { .. } => "deletion_kind_advisory_shape_ignored", 489 Self::KindAdvisoryInvalidIgnored { .. } => "deletion_kind_advisory_invalid_ignored", 490 Self::KindAdvisoryDuplicateIgnored { .. } => { 491 "deletion_kind_advisory_duplicate_ignored" 492 } 493 Self::KindAdvisoryConflictIgnored { .. } => { 494 "deletion_kind_advisory_conflict_ignored" 495 } 496 } 497 } 498 499 pub const fn tag_index(&self) -> usize { 500 match self { 501 Self::KindAdvisoryShapeIgnored { tag_index, .. } 502 | Self::KindAdvisoryInvalidIgnored { tag_index, .. } 503 | Self::KindAdvisoryDuplicateIgnored { tag_index, .. } 504 | Self::KindAdvisoryConflictIgnored { tag_index, .. } => *tag_index, 505 } 506 } 507 508 pub fn raw_tag(&self) -> &[String] { 509 match self { 510 Self::KindAdvisoryShapeIgnored { raw_tag, .. } 511 | Self::KindAdvisoryInvalidIgnored { raw_tag, .. } 512 | Self::KindAdvisoryDuplicateIgnored { raw_tag, .. } 513 | Self::KindAdvisoryConflictIgnored { raw_tag, .. } => raw_tag, 514 } 515 } 516 } 517 518 impl fmt::Display for RadrootsNip09DeletionDiagnostic { 519 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 520 formatter.write_str(self.code()) 521 } 522 } 523 524 #[derive(Clone, Debug, PartialEq, Eq)] 525 pub struct RadrootsInboundNip09DeletionEventTarget { 526 tag_index: usize, 527 event_id: EventId, 528 raw_tag: Vec<String>, 529 } 530 531 impl RadrootsInboundNip09DeletionEventTarget { 532 pub const fn tag_index(&self) -> usize { 533 self.tag_index 534 } 535 536 pub const fn event_id(&self) -> &EventId { 537 &self.event_id 538 } 539 540 pub fn raw_tag(&self) -> &[String] { 541 &self.raw_tag 542 } 543 } 544 545 #[derive(Clone, Debug, PartialEq, Eq)] 546 pub struct RadrootsInboundNip09DeletionAddressTarget { 547 tag_index: usize, 548 coordinate: Nip01Coordinate, 549 raw_tag: Vec<String>, 550 } 551 552 impl RadrootsInboundNip09DeletionAddressTarget { 553 pub const fn tag_index(&self) -> usize { 554 self.tag_index 555 } 556 557 pub const fn coordinate(&self) -> &Nip01Coordinate { 558 &self.coordinate 559 } 560 561 pub fn raw_tag(&self) -> &[String] { 562 &self.raw_tag 563 } 564 } 565 566 #[derive(Clone, Debug, PartialEq, Eq)] 567 pub struct RadrootsInboundNip09DeletionKindAdvisory { 568 tag_index: usize, 569 kind: u32, 570 raw_tag: Vec<String>, 571 } 572 573 impl RadrootsInboundNip09DeletionKindAdvisory { 574 pub const fn tag_index(&self) -> usize { 575 self.tag_index 576 } 577 578 pub const fn kind(&self) -> u32 { 579 self.kind 580 } 581 582 pub fn raw_tag(&self) -> &[String] { 583 &self.raw_tag 584 } 585 } 586 587 /// Tolerant effect-free projection of one verified kind-5 request. 588 /// 589 /// Raw tags preserve exact source order, duplicates, trailing elements, and 590 /// unknown tags. Canonical target and advisory views are unique and sorted, 591 /// retaining first-seen source provenance. 592 #[derive(Clone, Debug, PartialEq, Eq)] 593 pub struct RadrootsInboundNip09DeletionProjection { 594 event_targets: Vec<RadrootsInboundNip09DeletionEventTarget>, 595 address_targets: Vec<RadrootsInboundNip09DeletionAddressTarget>, 596 kind_advisories: Vec<RadrootsInboundNip09DeletionKindAdvisory>, 597 diagnostics: Vec<RadrootsNip09DeletionDiagnostic>, 598 raw_tags: Vec<Vec<String>>, 599 } 600 601 impl RadrootsInboundNip09DeletionProjection { 602 pub fn event_targets(&self) -> &[RadrootsInboundNip09DeletionEventTarget] { 603 &self.event_targets 604 } 605 606 pub fn address_targets(&self) -> &[RadrootsInboundNip09DeletionAddressTarget] { 607 &self.address_targets 608 } 609 610 pub fn kind_advisories(&self) -> &[RadrootsInboundNip09DeletionKindAdvisory] { 611 &self.kind_advisories 612 } 613 614 pub fn diagnostics(&self) -> &[RadrootsNip09DeletionDiagnostic] { 615 &self.diagnostics 616 } 617 618 pub fn raw_tags(&self) -> &[Vec<String>] { 619 &self.raw_tags 620 } 621 622 pub const fn contract_id(&self) -> &'static str { 623 "radroots.social.deletion_request.v1" 624 } 625 } 626 627 #[non_exhaustive] 628 #[derive(Clone, Debug, PartialEq, Eq)] 629 pub enum RadrootsNip09DeletionProjectionError { 630 UnsupportedKind { 631 actual: u32, 632 }, 633 ContentTooLarge { 634 max: usize, 635 actual: usize, 636 }, 637 TagCountExceeded { 638 max: usize, 639 actual: usize, 640 }, 641 TagElementCountExceeded { 642 max: usize, 643 actual: usize, 644 }, 645 TagElementTooLarge { 646 max: usize, 647 actual: usize, 648 tag_index: usize, 649 element_index: usize, 650 }, 651 TagBytesExceeded { 652 max: usize, 653 actual: usize, 654 }, 655 EventWireTooLarge { 656 max: usize, 657 actual: usize, 658 }, 659 EventTargetShape { 660 tag_index: usize, 661 }, 662 EventTargetInvalid { 663 tag_index: usize, 664 error: ParseError, 665 }, 666 AddressTargetShape { 667 tag_index: usize, 668 }, 669 AddressTargetInvalid { 670 tag_index: usize, 671 error: Nip01CoordinateParseError, 672 }, 673 TargetMissing, 674 } 675 676 impl RadrootsNip09DeletionProjectionError { 677 pub const fn code(&self) -> &'static str { 678 match self { 679 Self::UnsupportedKind { .. } => "unsupported_kind", 680 Self::ContentTooLarge { .. } => "deletion_content_too_large", 681 Self::TagCountExceeded { .. } => "deletion_tag_count_exceeded", 682 Self::TagElementCountExceeded { .. } => "deletion_tag_element_count_exceeded", 683 Self::TagElementTooLarge { .. } => "deletion_tag_element_too_large", 684 Self::TagBytesExceeded { .. } => "deletion_tag_bytes_exceeded", 685 Self::EventWireTooLarge { .. } => "deletion_event_wire_too_large", 686 Self::EventTargetShape { .. } => "deletion_event_target_shape", 687 Self::EventTargetInvalid { .. } => "deletion_event_target_invalid", 688 Self::AddressTargetShape { .. } => "deletion_address_target_shape", 689 Self::AddressTargetInvalid { .. } => "deletion_address_target_invalid", 690 Self::TargetMissing => "deletion_target_missing", 691 } 692 } 693 } 694 695 impl fmt::Display for RadrootsNip09DeletionProjectionError { 696 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 697 match self { 698 Self::UnsupportedKind { actual } => { 699 write!(formatter, "NIP-09 deletion kind must be 5, got {actual}") 700 } 701 Self::ContentTooLarge { max, actual } => write!( 702 formatter, 703 "NIP-09 deletion content is {actual} bytes; max is {max}" 704 ), 705 Self::TagCountExceeded { max, actual } => { 706 write!(formatter, "NIP-09 deletion has {actual} tags; max is {max}") 707 } 708 Self::TagElementCountExceeded { max, actual } => write!( 709 formatter, 710 "NIP-09 deletion has {actual} total tag elements; max is {max}" 711 ), 712 Self::TagElementTooLarge { 713 max, 714 actual, 715 tag_index, 716 element_index, 717 } => write!( 718 formatter, 719 "NIP-09 deletion tag {tag_index} element {element_index} is {actual} bytes; max is {max}" 720 ), 721 Self::TagBytesExceeded { max, actual } => write!( 722 formatter, 723 "NIP-09 deletion tag bytes are {actual}; max is {max}" 724 ), 725 Self::EventWireTooLarge { max, actual } => write!( 726 formatter, 727 "NIP-09 deletion compact signed event is {actual} bytes; max is {max}" 728 ), 729 Self::EventTargetShape { tag_index } => write!( 730 formatter, 731 "NIP-09 deletion event target tag {tag_index} has an invalid shape" 732 ), 733 Self::EventTargetInvalid { tag_index, error } => write!( 734 formatter, 735 "NIP-09 deletion event target tag {tag_index} is invalid: {error}" 736 ), 737 Self::AddressTargetShape { tag_index } => write!( 738 formatter, 739 "NIP-09 deletion address target tag {tag_index} has an invalid shape" 740 ), 741 Self::AddressTargetInvalid { tag_index, error } => write!( 742 formatter, 743 "NIP-09 deletion address target tag {tag_index} is invalid: {error}" 744 ), 745 Self::TargetMissing => { 746 formatter.write_str("NIP-09 deletion requires a valid event or address target") 747 } 748 } 749 } 750 } 751 752 #[cfg(feature = "std")] 753 impl std::error::Error for RadrootsNip09DeletionProjectionError { 754 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 755 match self { 756 Self::EventTargetInvalid { error, .. } => Some(error), 757 Self::AddressTargetInvalid { error, .. } => Some(error), 758 _ => None, 759 } 760 } 761 } 762 763 /// Projects a signature-and-id verified kind-5 NIP-09 deletion request. 764 /// 765 /// This boundary validates and canonicalizes request metadata only. It performs 766 /// no target lookup, same-author authorization, suppression, store mutation, 767 /// address cutoff, replacement, or deletion-request immunity evaluation. 768 pub fn project_verified_nip09_deletion_request_event( 769 verified_event: &RadrootsSignatureVerifiedEvent, 770 ) -> Result<RadrootsInboundNip09DeletionProjection, RadrootsNip09DeletionProjectionError> { 771 project_verified_nip09_deletion_request_event_v1(verified_event) 772 } 773 774 /// Projects a verified NIP-09 request with reconciliation-v1 semantics. 775 pub fn project_verified_nip09_deletion_request_event_v1( 776 verified_event: &RadrootsSignatureVerifiedEvent, 777 ) -> Result<RadrootsInboundNip09DeletionProjection, RadrootsNip09DeletionProjectionError> { 778 let event = verified_event.event(); 779 project_nip09_deletion_request_parts( 780 event.kind_u32(), 781 &event.tags_as_vec(), 782 event.content(), 783 event.created_at_u64(), 784 ) 785 } 786 787 pub(crate) fn project_nip09_deletion_request_parts( 788 kind: u32, 789 tags: &[Vec<String>], 790 content: &str, 791 created_at: u64, 792 ) -> Result<RadrootsInboundNip09DeletionProjection, RadrootsNip09DeletionProjectionError> { 793 if kind != KIND_DELETION_REQUEST { 794 return Err(RadrootsNip09DeletionProjectionError::UnsupportedKind { actual: kind }); 795 } 796 if content.len() > RADROOTS_NIP09_DELETION_CONTENT_MAX_BYTES { 797 return Err(RadrootsNip09DeletionProjectionError::ContentTooLarge { 798 max: RADROOTS_NIP09_DELETION_CONTENT_MAX_BYTES, 799 actual: content.len(), 800 }); 801 } 802 validate_tag_and_wire_budgets(tags, content, decimal_digits(created_at))?; 803 804 let mut event_targets = BTreeMap::new(); 805 let mut address_targets = BTreeMap::new(); 806 for (tag_index, tag) in tags.iter().enumerate() { 807 match tag.first().map(String::as_str) { 808 Some("e") => { 809 let Some(value) = tag.get(1) else { 810 return Err(RadrootsNip09DeletionProjectionError::EventTargetShape { 811 tag_index, 812 }); 813 }; 814 let event_id = EventId::parse(value).map_err(|error| { 815 RadrootsNip09DeletionProjectionError::EventTargetInvalid { 816 tag_index, 817 error, 818 } 819 })?; 820 event_targets.entry(event_id).or_insert_with(|| { 821 RadrootsInboundNip09DeletionEventTarget { 822 tag_index, 823 event_id, 824 raw_tag: tag.clone(), 825 } 826 }); 827 } 828 Some("a") => { 829 let Some(value) = tag.get(1) else { 830 return Err(RadrootsNip09DeletionProjectionError::AddressTargetShape { 831 tag_index, 832 }); 833 }; 834 let coordinate = Nip01Coordinate::parse(value).map_err(|error| { 835 RadrootsNip09DeletionProjectionError::AddressTargetInvalid { 836 tag_index, 837 error, 838 } 839 })?; 840 if !address_targets.contains_key(&coordinate) { 841 address_targets.insert( 842 coordinate.clone(), 843 RadrootsInboundNip09DeletionAddressTarget { 844 tag_index, 845 coordinate, 846 raw_tag: tag.clone(), 847 }, 848 ); 849 } 850 } 851 _ => {} 852 } 853 } 854 if event_targets.is_empty() && address_targets.is_empty() { 855 return Err(RadrootsNip09DeletionProjectionError::TargetMissing); 856 } 857 858 let has_event_targets = !event_targets.is_empty(); 859 let address_kinds = address_targets 860 .keys() 861 .map(Nip01Coordinate::kind) 862 .collect::<BTreeSet<_>>(); 863 let mut kind_advisories = BTreeMap::new(); 864 let mut diagnostics = Vec::new(); 865 for (tag_index, tag) in tags.iter().enumerate() { 866 if !tag.first().is_some_and(|name| name == "k") { 867 continue; 868 } 869 let Some(value) = tag.get(1) else { 870 diagnostics.push(RadrootsNip09DeletionDiagnostic::KindAdvisoryShapeIgnored { 871 tag_index, 872 raw_tag: tag.clone(), 873 }); 874 continue; 875 }; 876 let Ok(kind) = value.parse::<u32>() else { 877 diagnostics.push( 878 RadrootsNip09DeletionDiagnostic::KindAdvisoryInvalidIgnored { 879 tag_index, 880 raw_tag: tag.clone(), 881 }, 882 ); 883 continue; 884 }; 885 if kind > RADROOTS_NIP09_DELETION_TARGET_KIND_MAX || kind.to_string() != *value { 886 diagnostics.push( 887 RadrootsNip09DeletionDiagnostic::KindAdvisoryInvalidIgnored { 888 tag_index, 889 raw_tag: tag.clone(), 890 }, 891 ); 892 continue; 893 } 894 if kind_advisories.contains_key(&kind) { 895 diagnostics.push( 896 RadrootsNip09DeletionDiagnostic::KindAdvisoryDuplicateIgnored { 897 tag_index, 898 raw_tag: tag.clone(), 899 }, 900 ); 901 continue; 902 } 903 kind_advisories.insert( 904 kind, 905 RadrootsInboundNip09DeletionKindAdvisory { 906 tag_index, 907 kind, 908 raw_tag: tag.clone(), 909 }, 910 ); 911 } 912 913 if !has_event_targets { 914 for (kind, advisory) in &kind_advisories { 915 if !address_kinds.contains(kind) { 916 diagnostics.push( 917 RadrootsNip09DeletionDiagnostic::KindAdvisoryConflictIgnored { 918 tag_index: advisory.tag_index, 919 raw_tag: advisory.raw_tag.clone(), 920 }, 921 ); 922 } 923 } 924 } 925 diagnostics.sort_by_key(RadrootsNip09DeletionDiagnostic::tag_index); 926 927 Ok(RadrootsInboundNip09DeletionProjection { 928 event_targets: event_targets.into_values().collect(), 929 address_targets: address_targets.into_values().collect(), 930 kind_advisories: kind_advisories.into_values().collect(), 931 diagnostics, 932 raw_tags: tags.to_vec(), 933 }) 934 } 935 936 fn validate_tag_and_wire_budgets( 937 tags: &[Vec<String>], 938 content: &str, 939 created_at_digits: usize, 940 ) -> Result<(), RadrootsNip09DeletionProjectionError> { 941 if tags.len() > RADROOTS_NIP09_DELETION_TAG_MAX_COUNT { 942 return Err(RadrootsNip09DeletionProjectionError::TagCountExceeded { 943 max: RADROOTS_NIP09_DELETION_TAG_MAX_COUNT, 944 actual: tags.len(), 945 }); 946 } 947 let tag_element_count = tags 948 .iter() 949 .fold(0usize, |total, tag| total.saturating_add(tag.len())); 950 if tag_element_count > RADROOTS_NIP09_DELETION_TAG_TOTAL_ELEMENT_MAX_COUNT { 951 return Err( 952 RadrootsNip09DeletionProjectionError::TagElementCountExceeded { 953 max: RADROOTS_NIP09_DELETION_TAG_TOTAL_ELEMENT_MAX_COUNT, 954 actual: tag_element_count, 955 }, 956 ); 957 } 958 959 let mut tag_bytes = 0usize; 960 let mut tags_json_bytes = 2usize; 961 for (tag_index, tag) in tags.iter().enumerate() { 962 if tag_index > 0 { 963 tags_json_bytes = tags_json_bytes.saturating_add(1); 964 } 965 tags_json_bytes = tags_json_bytes.saturating_add(2); 966 for (element_index, element) in tag.iter().enumerate() { 967 if element.len() > RADROOTS_NIP09_DELETION_TAG_ELEMENT_MAX_BYTES { 968 return Err(RadrootsNip09DeletionProjectionError::TagElementTooLarge { 969 max: RADROOTS_NIP09_DELETION_TAG_ELEMENT_MAX_BYTES, 970 actual: element.len(), 971 tag_index, 972 element_index, 973 }); 974 } 975 if element_index > 0 { 976 tags_json_bytes = tags_json_bytes.saturating_add(1); 977 } 978 tags_json_bytes = 979 tags_json_bytes.saturating_add(canonical_json_string_bytes(element)); 980 tag_bytes = tag_bytes.saturating_add(element.len()); 981 } 982 } 983 if tag_bytes > RADROOTS_NIP09_DELETION_TAG_TOTAL_MAX_BYTES { 984 return Err(RadrootsNip09DeletionProjectionError::TagBytesExceeded { 985 max: RADROOTS_NIP09_DELETION_TAG_TOTAL_MAX_BYTES, 986 actual: tag_bytes, 987 }); 988 } 989 990 let actual = RADROOTS_NIP09_DELETION_SIGNED_EVENT_FIXED_BYTES 991 .saturating_add(created_at_digits) 992 .saturating_add(tags_json_bytes) 993 .saturating_add(canonical_json_string_bytes(content)); 994 if actual > RADROOTS_NIP09_DELETION_EVENT_WIRE_MAX_BYTES { 995 return Err(RadrootsNip09DeletionProjectionError::EventWireTooLarge { 996 max: RADROOTS_NIP09_DELETION_EVENT_WIRE_MAX_BYTES, 997 actual, 998 }); 999 } 1000 Ok(()) 1001 } 1002 1003 fn canonical_json_string_bytes(value: &str) -> usize { 1004 value.chars().fold(2usize, |total, character| { 1005 total.saturating_add(match character { 1006 '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2, 1007 '\u{0000}'..='\u{001f}' => 6, 1008 _ => character.len_utf8(), 1009 }) 1010 }) 1011 } 1012 1013 const fn decimal_digits(mut value: u64) -> usize { 1014 let mut digits = 1usize; 1015 while value >= 10 { 1016 value /= 10; 1017 digits += 1; 1018 } 1019 digits 1020 } 1021 1022 #[cfg(test)] 1023 mod tests; 1024 }