lib

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

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 }