lib

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

deletion.rs (30067B)


      1 #![forbid(unsafe_code)]
      2 
      3 #[cfg(not(feature = "std"))]
      4 use alloc::{
      5     string::{String, ToString},
      6     vec::Vec,
      7 };
      8 #[cfg(feature = "std")]
      9 use std::{string::String, vec::Vec};
     10 
     11 use core::fmt;
     12 
     13 use crate::{
     14     id::{EventId, Nip01Coordinate, Nip01CoordinateParseError, ParseError},
     15     wire::{
     16         DEFAULT_CONTENT_MAX_BYTES, DEFAULT_RAW_JSON_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES,
     17         DEFAULT_TAG_MAX_COUNT, DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT, DEFAULT_TAG_TOTAL_MAX_BYTES,
     18     },
     19 };
     20 
     21 pub const RADROOTS_NIP09_DELETION_CONTENT_MAX_BYTES: usize = DEFAULT_CONTENT_MAX_BYTES;
     22 pub const RADROOTS_NIP09_DELETION_TAG_MAX_COUNT: usize = DEFAULT_TAG_MAX_COUNT;
     23 pub const RADROOTS_NIP09_DELETION_TAG_TOTAL_ELEMENT_MAX_COUNT: usize =
     24     DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT;
     25 pub const RADROOTS_NIP09_DELETION_TAG_ELEMENT_MAX_BYTES: usize = DEFAULT_TAG_ELEMENT_MAX_BYTES;
     26 pub const RADROOTS_NIP09_DELETION_TAG_TOTAL_MAX_BYTES: usize = DEFAULT_TAG_TOTAL_MAX_BYTES;
     27 pub const RADROOTS_NIP09_DELETION_EVENT_WIRE_MAX_BYTES: usize = DEFAULT_RAW_JSON_MAX_BYTES;
     28 pub const RADROOTS_NIP09_DELETION_TARGET_KIND_MAX: u32 = u16::MAX as u32;
     29 
     30 const RADROOTS_NIP09_DELETION_SIGNED_EVENT_FIXED_MAX_BYTES: usize = "{\"id\":\"".len()
     31     + 64
     32     + "\",\"pubkey\":\"".len()
     33     + 64
     34     + "\",\"created_at\":".len()
     35     + 20
     36     + ",\"kind\":5,\"tags\":".len()
     37     + ",\"content\":".len()
     38     + ",\"sig\":\"".len()
     39     + 128
     40     + "\"}".len();
     41 
     42 #[non_exhaustive]
     43 #[derive(Clone, Debug, PartialEq, Eq)]
     44 pub enum Nip09DeletionError {
     45     ContentTooLarge { max: usize, actual: usize },
     46     EventIdInvalid(ParseError),
     47     CoordinateInvalid(Nip01CoordinateParseError),
     48     TargetKindOutOfRange { max: u32, actual: u32 },
     49     DuplicateEventTarget { event_id: String },
     50     DuplicateAddressTarget { coordinate: String },
     51     TargetMissing,
     52     TagCountExceeded { max: usize, actual: usize },
     53     TagElementTooLarge { max: usize, actual: usize },
     54     TagBytesExceeded { max: usize, actual: usize },
     55     EventWireTooLarge { max: usize, actual: usize },
     56 }
     57 
     58 impl Nip09DeletionError {
     59     pub const fn code(&self) -> &'static str {
     60         match self {
     61             Self::ContentTooLarge { .. } => "deletion_content_too_large",
     62             Self::EventIdInvalid(_) | Self::TargetKindOutOfRange { .. } => {
     63                 "deletion_event_target_invalid"
     64             }
     65             Self::CoordinateInvalid(_) => "deletion_address_target_invalid",
     66             Self::DuplicateEventTarget { .. } => "deletion_event_target_duplicate",
     67             Self::DuplicateAddressTarget { .. } => "deletion_address_target_duplicate",
     68             Self::TargetMissing => "deletion_target_missing",
     69             Self::TagCountExceeded { .. } => "deletion_tag_count_exceeded",
     70             Self::TagElementTooLarge { .. } => "deletion_tag_element_too_large",
     71             Self::TagBytesExceeded { .. } => "deletion_tag_bytes_exceeded",
     72             Self::EventWireTooLarge { .. } => "deletion_event_wire_too_large",
     73         }
     74     }
     75 }
     76 
     77 impl fmt::Display for Nip09DeletionError {
     78     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     79         match self {
     80             Self::ContentTooLarge { max, actual } => write!(
     81                 formatter,
     82                 "authored NIP-09 deletion content is {actual} bytes; max is {max}"
     83             ),
     84             Self::EventIdInvalid(error) => {
     85                 write!(
     86                     formatter,
     87                     "NIP-09 deletion event target is invalid: {error}"
     88                 )
     89             }
     90             Self::CoordinateInvalid(error) => {
     91                 write!(
     92                     formatter,
     93                     "NIP-09 deletion address target is invalid: {error}"
     94                 )
     95             }
     96             Self::TargetKindOutOfRange { max, actual } => write!(
     97                 formatter,
     98                 "NIP-09 deletion event target kind {actual} exceeds {max}"
     99             ),
    100             Self::DuplicateEventTarget { event_id } => write!(
    101                 formatter,
    102                 "NIP-09 deletion event target `{event_id}` is duplicated"
    103             ),
    104             Self::DuplicateAddressTarget { coordinate } => write!(
    105                 formatter,
    106                 "NIP-09 deletion address target {coordinate:?} is duplicated"
    107             ),
    108             Self::TargetMissing => {
    109                 formatter.write_str("authored NIP-09 deletion requires an event or address target")
    110             }
    111             Self::TagCountExceeded { max, actual } => write!(
    112                 formatter,
    113                 "authored NIP-09 deletion has {actual} tags; max is {max}"
    114             ),
    115             Self::TagElementTooLarge { max, actual } => write!(
    116                 formatter,
    117                 "authored NIP-09 deletion tag element is {actual} bytes; max is {max}"
    118             ),
    119             Self::TagBytesExceeded { max, actual } => write!(
    120                 formatter,
    121                 "authored NIP-09 deletion tag bytes are {actual}; max is {max}"
    122             ),
    123             Self::EventWireTooLarge { max, actual } => write!(
    124                 formatter,
    125                 "authored NIP-09 deletion maximum canonical signed event size is {actual} bytes; max is {max}"
    126             ),
    127         }
    128     }
    129 }
    130 
    131 #[cfg(feature = "std")]
    132 impl std::error::Error for Nip09DeletionError {
    133     fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
    134         match self {
    135             Self::EventIdInvalid(error) => Some(error),
    136             Self::CoordinateInvalid(error) => Some(error),
    137             _ => None,
    138         }
    139     }
    140 }
    141 
    142 /// One event-id target with caller-asserted target-kind metadata.
    143 ///
    144 /// The kind hint is required for canonical authored `k` tags. This type does
    145 /// not prove that the target exists or actually has the asserted kind.
    146 #[derive(Clone, Debug, PartialEq, Eq)]
    147 pub struct Nip09DeletionEventTarget {
    148     event_id: EventId,
    149     kind_hint: u32,
    150 }
    151 
    152 impl Nip09DeletionEventTarget {
    153     pub fn new(event_id: EventId, kind_hint: u32) -> Result<Self, Nip09DeletionError> {
    154         if kind_hint > RADROOTS_NIP09_DELETION_TARGET_KIND_MAX {
    155             return Err(Nip09DeletionError::TargetKindOutOfRange {
    156                 max: RADROOTS_NIP09_DELETION_TARGET_KIND_MAX,
    157                 actual: kind_hint,
    158             });
    159         }
    160         Ok(Self {
    161             event_id,
    162             kind_hint,
    163         })
    164     }
    165 
    166     pub fn parse(event_id: impl AsRef<str>, kind_hint: u32) -> Result<Self, Nip09DeletionError> {
    167         Self::new(
    168             EventId::parse(event_id).map_err(Nip09DeletionError::EventIdInvalid)?,
    169             kind_hint,
    170         )
    171     }
    172 
    173     #[inline]
    174     pub const fn event_id(&self) -> &EventId {
    175         &self.event_id
    176     }
    177 
    178     #[inline]
    179     pub const fn kind_hint(&self) -> u32 {
    180         self.kind_hint
    181     }
    182 }
    183 
    184 /// One NIP-01 replaceable or addressable coordinate target.
    185 #[derive(Clone, Debug, PartialEq, Eq)]
    186 pub struct Nip09DeletionAddressTarget {
    187     coordinate: Nip01Coordinate,
    188 }
    189 
    190 impl Nip09DeletionAddressTarget {
    191     pub const fn new(coordinate: Nip01Coordinate) -> Self {
    192         Self { coordinate }
    193     }
    194 
    195     pub fn parse(coordinate: impl AsRef<str>) -> Result<Self, Nip09DeletionError> {
    196         let coordinate = coordinate.as_ref();
    197         if coordinate.len() > RADROOTS_NIP09_DELETION_TAG_ELEMENT_MAX_BYTES {
    198             return Err(Nip09DeletionError::TagElementTooLarge {
    199                 max: RADROOTS_NIP09_DELETION_TAG_ELEMENT_MAX_BYTES,
    200                 actual: coordinate.len(),
    201             });
    202         }
    203         Nip01Coordinate::parse(coordinate)
    204             .map(Self::new)
    205             .map_err(Nip09DeletionError::CoordinateInvalid)
    206     }
    207 
    208     #[inline]
    209     pub const fn coordinate(&self) -> &Nip01Coordinate {
    210         &self.coordinate
    211     }
    212 
    213     #[inline]
    214     pub const fn kind_hint(&self) -> u32 {
    215         self.coordinate.kind()
    216     }
    217 }
    218 
    219 /// Strict authored kind-5 NIP-09 deletion request.
    220 ///
    221 /// Targets are canonicalized at construction. Event targets sort by event ID,
    222 /// address targets sort by coordinate, and derived kind hints are unique and
    223 /// ascending. The request remains an immutable protocol statement; it performs
    224 /// no target-author authorization or deletion effect.
    225 ///
    226 /// This type is opaque and has no Serde construction path.
    227 ///
    228 /// ```compile_fail
    229 /// let _: radroots_event::post::deletion::AuthoredNip09DeletionRequest =
    230 ///     serde_json::from_str(
    231 ///         r#"{"content":"","event_targets":[],"address_targets":[]}"#
    232 ///     ).unwrap();
    233 /// ```
    234 #[derive(Clone, Debug, PartialEq, Eq)]
    235 pub struct AuthoredNip09DeletionRequest {
    236     content: String,
    237     event_targets: Vec<Nip09DeletionEventTarget>,
    238     address_targets: Vec<Nip09DeletionAddressTarget>,
    239     kind_hints: Vec<u32>,
    240     maximum_signed_event_wire_bytes: usize,
    241 }
    242 
    243 impl AuthoredNip09DeletionRequest {
    244     pub fn new(
    245         content: impl Into<String>,
    246         mut event_targets: Vec<Nip09DeletionEventTarget>,
    247         mut address_targets: Vec<Nip09DeletionAddressTarget>,
    248     ) -> Result<Self, Nip09DeletionError> {
    249         let content = content.into();
    250         if content.len() > RADROOTS_NIP09_DELETION_CONTENT_MAX_BYTES {
    251             return Err(Nip09DeletionError::ContentTooLarge {
    252                 max: RADROOTS_NIP09_DELETION_CONTENT_MAX_BYTES,
    253                 actual: content.len(),
    254             });
    255         }
    256         if event_targets.is_empty() && address_targets.is_empty() {
    257             return Err(Nip09DeletionError::TargetMissing);
    258         }
    259 
    260         let kind_hints = collect_authored_deletion_kind_hints(&event_targets, &address_targets)?;
    261 
    262         let maximum_signed_event_wire_bytes = validate_authored_deletion_wire_size(
    263             &content,
    264             &event_targets,
    265             &address_targets,
    266             &kind_hints,
    267         )?;
    268 
    269         event_targets.sort_by_key(|target| target.event_id);
    270         if let Some(duplicates) = event_targets
    271             .windows(2)
    272             .find(|targets| targets[0].event_id == targets[1].event_id)
    273         {
    274             return Err(Nip09DeletionError::DuplicateEventTarget {
    275                 event_id: duplicates[0].event_id.to_string(),
    276             });
    277         }
    278 
    279         address_targets.sort_by(|left, right| left.coordinate.cmp(&right.coordinate));
    280         if let Some(duplicates) = address_targets
    281             .windows(2)
    282             .find(|targets| targets[0].coordinate == targets[1].coordinate)
    283         {
    284             return Err(Nip09DeletionError::DuplicateAddressTarget {
    285                 coordinate: duplicates[0].coordinate.to_string(),
    286             });
    287         }
    288 
    289         Ok(Self {
    290             content,
    291             event_targets,
    292             address_targets,
    293             kind_hints,
    294             maximum_signed_event_wire_bytes,
    295         })
    296     }
    297 
    298     #[inline]
    299     pub fn content(&self) -> &str {
    300         self.content.as_str()
    301     }
    302 
    303     #[inline]
    304     pub fn event_targets(&self) -> &[Nip09DeletionEventTarget] {
    305         self.event_targets.as_slice()
    306     }
    307 
    308     #[inline]
    309     pub fn address_targets(&self) -> &[Nip09DeletionAddressTarget] {
    310         self.address_targets.as_slice()
    311     }
    312 
    313     #[inline]
    314     pub fn kind_hints(&self) -> &[u32] {
    315         self.kind_hints.as_slice()
    316     }
    317 
    318     #[inline]
    319     pub fn target_count(&self) -> usize {
    320         self.event_targets
    321             .len()
    322             .saturating_add(self.address_targets.len())
    323     }
    324 
    325     /// Compact canonical signed-event size using `u64::MAX` for `created_at`.
    326     #[inline]
    327     pub const fn maximum_signed_event_wire_bytes(&self) -> usize {
    328         self.maximum_signed_event_wire_bytes
    329     }
    330 }
    331 
    332 fn collect_authored_deletion_kind_hints(
    333     event_targets: &[Nip09DeletionEventTarget],
    334     address_targets: &[Nip09DeletionAddressTarget],
    335 ) -> Result<Vec<u32>, Nip09DeletionError> {
    336     const WORD_BITS: usize = u64::BITS as usize;
    337     const WORD_COUNT: usize = (RADROOTS_NIP09_DELETION_TARGET_KIND_MAX as usize + 1) / WORD_BITS;
    338 
    339     let mut seen = [0_u64; WORD_COUNT];
    340     let mut unique_kind_count = 0usize;
    341     for kind in event_targets
    342         .iter()
    343         .map(|target| target.kind_hint())
    344         .chain(address_targets.iter().map(|target| target.kind_hint()))
    345     {
    346         let kind = kind as usize;
    347         let word = kind / WORD_BITS;
    348         let mask = 1_u64 << (kind % WORD_BITS);
    349         if seen[word] & mask == 0 {
    350             seen[word] |= mask;
    351             unique_kind_count = unique_kind_count.saturating_add(1);
    352         }
    353     }
    354 
    355     let target_count = event_targets.len().saturating_add(address_targets.len());
    356     let tag_count = target_count.saturating_add(unique_kind_count);
    357     if tag_count > RADROOTS_NIP09_DELETION_TAG_MAX_COUNT {
    358         return Err(Nip09DeletionError::TagCountExceeded {
    359             max: RADROOTS_NIP09_DELETION_TAG_MAX_COUNT,
    360             actual: tag_count,
    361         });
    362     }
    363 
    364     let mut kind_hints = Vec::with_capacity(unique_kind_count);
    365     for (word_index, word) in seen.into_iter().enumerate() {
    366         let mut remaining = word;
    367         while remaining != 0 {
    368             let bit = remaining.trailing_zeros() as usize;
    369             kind_hints.push((word_index * WORD_BITS + bit) as u32);
    370             remaining &= remaining - 1;
    371         }
    372     }
    373     Ok(kind_hints)
    374 }
    375 
    376 fn validate_authored_deletion_wire_size(
    377     content: &str,
    378     event_targets: &[Nip09DeletionEventTarget],
    379     address_targets: &[Nip09DeletionAddressTarget],
    380     kind_hints: &[u32],
    381 ) -> Result<usize, Nip09DeletionError> {
    382     let tag_count = event_targets
    383         .len()
    384         .saturating_add(address_targets.len())
    385         .saturating_add(kind_hints.len());
    386     if tag_count > RADROOTS_NIP09_DELETION_TAG_MAX_COUNT {
    387         return Err(Nip09DeletionError::TagCountExceeded {
    388             max: RADROOTS_NIP09_DELETION_TAG_MAX_COUNT,
    389             actual: tag_count,
    390         });
    391     }
    392 
    393     let mut tag_bytes = 0usize;
    394     let mut tags_json_bytes = 2usize;
    395     let mut visited_tags = 0usize;
    396     for target in event_targets {
    397         let event_id = target.event_id().to_hex();
    398         add_tag_size(
    399             &mut tag_bytes,
    400             &mut tags_json_bytes,
    401             &mut visited_tags,
    402             "e",
    403             event_id.as_str(),
    404         )?;
    405     }
    406     for target in address_targets {
    407         add_tag_size(
    408             &mut tag_bytes,
    409             &mut tags_json_bytes,
    410             &mut visited_tags,
    411             "a",
    412             target.coordinate().as_str(),
    413         )?;
    414     }
    415     for kind in kind_hints {
    416         let kind = kind.to_string();
    417         add_tag_size(
    418             &mut tag_bytes,
    419             &mut tags_json_bytes,
    420             &mut visited_tags,
    421             "k",
    422             kind.as_str(),
    423         )?;
    424     }
    425 
    426     if tag_bytes > RADROOTS_NIP09_DELETION_TAG_TOTAL_MAX_BYTES {
    427         return Err(Nip09DeletionError::TagBytesExceeded {
    428             max: RADROOTS_NIP09_DELETION_TAG_TOTAL_MAX_BYTES,
    429             actual: tag_bytes,
    430         });
    431     }
    432 
    433     let actual = RADROOTS_NIP09_DELETION_SIGNED_EVENT_FIXED_MAX_BYTES
    434         .saturating_add(tags_json_bytes)
    435         .saturating_add(canonical_json_string_bytes(content));
    436     if actual > RADROOTS_NIP09_DELETION_EVENT_WIRE_MAX_BYTES {
    437         return Err(Nip09DeletionError::EventWireTooLarge {
    438             max: RADROOTS_NIP09_DELETION_EVENT_WIRE_MAX_BYTES,
    439             actual,
    440         });
    441     }
    442     Ok(actual)
    443 }
    444 
    445 fn add_tag_size(
    446     tag_bytes: &mut usize,
    447     tags_json_bytes: &mut usize,
    448     tag_count: &mut usize,
    449     name: &str,
    450     value: &str,
    451 ) -> Result<(), Nip09DeletionError> {
    452     for element in [name, value] {
    453         if element.len() > RADROOTS_NIP09_DELETION_TAG_ELEMENT_MAX_BYTES {
    454             return Err(Nip09DeletionError::TagElementTooLarge {
    455                 max: RADROOTS_NIP09_DELETION_TAG_ELEMENT_MAX_BYTES,
    456                 actual: element.len(),
    457             });
    458         }
    459         *tag_bytes = tag_bytes.saturating_add(element.len());
    460     }
    461     if *tag_count > 0 {
    462         *tags_json_bytes = tags_json_bytes.saturating_add(1);
    463     }
    464     *tags_json_bytes = tags_json_bytes
    465         .saturating_add(2)
    466         .saturating_add(canonical_json_string_bytes(name))
    467         .saturating_add(1)
    468         .saturating_add(canonical_json_string_bytes(value));
    469     *tag_count = tag_count.saturating_add(1);
    470     Ok(())
    471 }
    472 
    473 fn canonical_json_string_bytes(value: &str) -> usize {
    474     value.chars().fold(2usize, |total, character| {
    475         total.saturating_add(match character {
    476             '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2,
    477             '\u{0000}'..='\u{001f}' => 6,
    478             _ => character.len_utf8(),
    479         })
    480     })
    481 }
    482 
    483 #[cfg(test)]
    484 #[cfg_attr(coverage_nightly, coverage(off))]
    485 mod tests {
    486     use super::*;
    487     use crate::id::RADROOTS_NIP01_COORDINATE_MAX_BYTES;
    488 
    489     fn event_target(character: char, kind: u32) -> Nip09DeletionEventTarget {
    490         Nip09DeletionEventTarget::parse(character.to_string().repeat(64), kind)
    491             .expect("event target")
    492     }
    493 
    494     fn numeric_event_target(index: usize, kind: u32) -> Nip09DeletionEventTarget {
    495         Nip09DeletionEventTarget::parse(format!("{index:064x}"), kind)
    496             .expect("numeric event target")
    497     }
    498 
    499     fn address_target(kind: u32, character: char, identifier: &str) -> Nip09DeletionAddressTarget {
    500         Nip09DeletionAddressTarget::parse(format!(
    501             "{kind}:{}:{identifier}",
    502             crate::test_valid_hex_64(character)
    503         ))
    504         .expect("address target")
    505     }
    506 
    507     fn address_target_with_total_bytes(
    508         total_bytes: usize,
    509         index: usize,
    510     ) -> Nip09DeletionAddressTarget {
    511         let prefix = format!("30000:{}:", crate::test_valid_hex_64('a'));
    512         let suffix = format!("{index:04x}");
    513         assert!(prefix.len() + suffix.len() <= total_bytes);
    514         Nip09DeletionAddressTarget::parse(format!(
    515             "{prefix}{}{suffix}",
    516             "x".repeat(total_bytes - prefix.len() - suffix.len())
    517         ))
    518         .expect("fixed-size address target")
    519     }
    520 
    521     #[test]
    522     fn target_constructors_validate_identity_and_kind_hint() {
    523         let target =
    524             Nip09DeletionEventTarget::parse("A".repeat(64), u16::MAX as u32).expect("event target");
    525         assert_eq!(target.event_id().to_hex(), "a".repeat(64));
    526         assert_eq!(target.kind_hint(), u16::MAX as u32);
    527         assert_eq!(
    528             Nip09DeletionEventTarget::parse("5".repeat(64), 5)
    529                 .expect("kind-5 target")
    530                 .kind_hint(),
    531             5
    532         );
    533         assert!(matches!(
    534             Nip09DeletionEventTarget::parse("not-an-id", 1),
    535             Err(Nip09DeletionError::EventIdInvalid(
    536                 ParseError::InvalidLength {
    537                     expected: 64,
    538                     actual: 9
    539                 }
    540             ))
    541         ));
    542         assert_eq!(
    543             Nip09DeletionEventTarget::parse("a".repeat(64), u16::MAX as u32 + 1).unwrap_err(),
    544             Nip09DeletionError::TargetKindOutOfRange {
    545                 max: u16::MAX as u32,
    546                 actual: u16::MAX as u32 + 1,
    547             }
    548         );
    549 
    550         let address =
    551             Nip09DeletionAddressTarget::parse(format!("30000:{}:", crate::test_valid_hex_64('B')))
    552                 .expect("address target");
    553         assert_eq!(
    554             address.coordinate().as_str(),
    555             format!("30000:{}:", crate::test_valid_hex_64('b'))
    556         );
    557         assert_eq!(address.kind_hint(), 30_000);
    558 
    559         let coordinate_prefix = format!("30000:{}:", "a".repeat(64));
    560         let oversized_coordinate = format!(
    561             "{coordinate_prefix}{}",
    562             "x".repeat(RADROOTS_NIP09_DELETION_TAG_ELEMENT_MAX_BYTES + 1 - coordinate_prefix.len())
    563         );
    564         assert_eq!(
    565             Nip09DeletionAddressTarget::parse(oversized_coordinate),
    566             Err(Nip09DeletionError::TagElementTooLarge {
    567                 max: RADROOTS_NIP09_DELETION_TAG_ELEMENT_MAX_BYTES,
    568                 actual: RADROOTS_NIP09_DELETION_TAG_ELEMENT_MAX_BYTES + 1,
    569             })
    570         );
    571     }
    572 
    573     #[test]
    574     fn authored_request_allows_event_address_and_mixed_batches() {
    575         let event_only =
    576             AuthoredNip09DeletionRequest::new("", vec![event_target('a', 1)], Vec::new())
    577                 .expect("event-only request");
    578         assert_eq!(event_only.target_count(), 1);
    579         assert_eq!(event_only.kind_hints(), &[1]);
    580 
    581         let address_only = AuthoredNip09DeletionRequest::new(
    582             "withdrawn",
    583             Vec::new(),
    584             vec![address_target(30_402, 'b', "victoria-kale")],
    585         )
    586         .expect("address-only request");
    587         assert_eq!(address_only.target_count(), 1);
    588         assert_eq!(address_only.kind_hints(), &[30_402]);
    589 
    590         let mixed = AuthoredNip09DeletionRequest::new(
    591             "\t撀回 🌱\n",
    592             vec![event_target('c', 31_922)],
    593             vec![address_target(30_402, 'd', "victoria-carrots")],
    594         )
    595         .expect("mixed request");
    596         assert_eq!(mixed.content(), "\t撀回 🌱\n");
    597         assert_eq!(mixed.target_count(), 2);
    598         assert_eq!(mixed.kind_hints(), &[30_402, 31_922]);
    599     }
    600 
    601     #[test]
    602     fn authored_request_sorts_targets_and_deduplicates_kind_hints() {
    603         let request = AuthoredNip09DeletionRequest::new(
    604             "duplicate crop listing",
    605             vec![event_target('f', 30_402), event_target('a', 1)],
    606             vec![
    607                 address_target(31_923, 'e', "harvest"),
    608                 address_target(30_402, 'b', "produce"),
    609             ],
    610         )
    611         .expect("canonical request");
    612 
    613         assert_eq!(
    614             request.event_targets()[0].event_id().to_hex(),
    615             "a".repeat(64)
    616         );
    617         assert_eq!(
    618             request.event_targets()[1].event_id().to_hex(),
    619             "f".repeat(64)
    620         );
    621         assert_eq!(
    622             request.address_targets()[0].coordinate().as_str(),
    623             format!("30402:{}:produce", crate::test_valid_hex_64('b'))
    624         );
    625         assert_eq!(
    626             request.address_targets()[1].coordinate().as_str(),
    627             format!("31923:{}:harvest", crate::test_valid_hex_64('e'))
    628         );
    629         assert_eq!(request.kind_hints(), &[1, 30_402, 31_923]);
    630     }
    631 
    632     #[test]
    633     fn authored_request_rejects_canonical_duplicate_targets() {
    634         let uppercase =
    635             Nip09DeletionEventTarget::parse("A".repeat(64), 1).expect("uppercase event");
    636         let lowercase =
    637             Nip09DeletionEventTarget::parse("a".repeat(64), 31_922).expect("lowercase event");
    638         assert_eq!(
    639             AuthoredNip09DeletionRequest::new("", vec![uppercase, lowercase], Vec::new(),)
    640                 .unwrap_err(),
    641             Nip09DeletionError::DuplicateEventTarget {
    642                 event_id: "a".repeat(64)
    643             }
    644         );
    645 
    646         let uppercase_address = Nip09DeletionAddressTarget::parse(format!(
    647             "030402:{}:produce",
    648             crate::test_valid_hex_64('A')
    649         ))
    650         .expect("uppercase address");
    651         assert_eq!(
    652             AuthoredNip09DeletionRequest::new(
    653                 "",
    654                 Vec::new(),
    655                 vec![uppercase_address, address_target(30_402, 'a', "produce"),],
    656             )
    657             .unwrap_err(),
    658             Nip09DeletionError::DuplicateAddressTarget {
    659                 coordinate: format!("30402:{}:produce", crate::test_valid_hex_64('a'))
    660             }
    661         );
    662     }
    663 
    664     #[test]
    665     fn authored_request_requires_target_union_and_caps_content() {
    666         assert_eq!(
    667             AuthoredNip09DeletionRequest::new("", Vec::new(), Vec::new()).unwrap_err(),
    668             Nip09DeletionError::TargetMissing
    669         );
    670         AuthoredNip09DeletionRequest::new(
    671             "x".repeat(RADROOTS_NIP09_DELETION_CONTENT_MAX_BYTES),
    672             vec![event_target('a', 1)],
    673             Vec::new(),
    674         )
    675         .expect("exact content byte limit");
    676         assert_eq!(
    677             AuthoredNip09DeletionRequest::new(
    678                 "x".repeat(RADROOTS_NIP09_DELETION_CONTENT_MAX_BYTES + 1),
    679                 vec![event_target('a', 1)],
    680                 Vec::new(),
    681             )
    682             .unwrap_err(),
    683             Nip09DeletionError::ContentTooLarge {
    684                 max: RADROOTS_NIP09_DELETION_CONTENT_MAX_BYTES,
    685                 actual: RADROOTS_NIP09_DELETION_CONTENT_MAX_BYTES + 1,
    686             }
    687         );
    688     }
    689 
    690     #[test]
    691     fn authored_request_enforces_tag_count_budget() {
    692         let exact_targets = (0..RADROOTS_NIP09_DELETION_TAG_MAX_COUNT - 1)
    693             .map(|index| numeric_event_target(index, 1))
    694             .collect();
    695         AuthoredNip09DeletionRequest::new("", exact_targets, Vec::new())
    696             .expect("1023 targets plus one kind tag");
    697 
    698         let overflow_targets = (0..RADROOTS_NIP09_DELETION_TAG_MAX_COUNT)
    699             .map(|index| numeric_event_target(index, 1))
    700             .collect();
    701         assert_eq!(
    702             AuthoredNip09DeletionRequest::new("", overflow_targets, Vec::new()).unwrap_err(),
    703             Nip09DeletionError::TagCountExceeded {
    704                 max: RADROOTS_NIP09_DELETION_TAG_MAX_COUNT,
    705                 actual: RADROOTS_NIP09_DELETION_TAG_MAX_COUNT + 1,
    706             }
    707         );
    708 
    709         let duplicate_overflow = vec![event_target('a', 1); RADROOTS_NIP09_DELETION_TAG_MAX_COUNT];
    710         assert_eq!(
    711             AuthoredNip09DeletionRequest::new("", duplicate_overflow, Vec::new()).unwrap_err(),
    712             Nip09DeletionError::TagCountExceeded {
    713                 max: RADROOTS_NIP09_DELETION_TAG_MAX_COUNT,
    714                 actual: RADROOTS_NIP09_DELETION_TAG_MAX_COUNT + 1,
    715             }
    716         );
    717     }
    718 
    719     #[test]
    720     fn duplicate_address_error_escapes_opaque_identifier_controls() {
    721         let target = address_target(30_000, 'b', "line\nbreak");
    722         let error = AuthoredNip09DeletionRequest::new("", Vec::new(), vec![target.clone(), target])
    723             .unwrap_err();
    724         let rendered = error.to_string();
    725         assert!(rendered.contains("\\n"));
    726         assert!(!rendered.contains('\n'));
    727     }
    728 
    729     #[test]
    730     fn authored_request_enforces_exact_aggregate_tag_byte_budget() {
    731         let mut exact_targets = (0..31)
    732             .map(|index| {
    733                 address_target_with_total_bytes(RADROOTS_NIP01_COORDINATE_MAX_BYTES, index)
    734             })
    735             .collect::<Vec<_>>();
    736         exact_targets.push(address_target_with_total_bytes(4_058, 31));
    737         let exact_tag_bytes = exact_targets
    738             .iter()
    739             .map(|target| 1 + target.coordinate().as_str().len())
    740             .sum::<usize>()
    741             + "k".len()
    742             + "30000".len();
    743         assert_eq!(exact_tag_bytes, RADROOTS_NIP09_DELETION_TAG_TOTAL_MAX_BYTES);
    744         AuthoredNip09DeletionRequest::new("", Vec::new(), exact_targets)
    745             .expect("exact aggregate tag byte limit");
    746 
    747         let mut overflow_targets = (0..31)
    748             .map(|index| {
    749                 address_target_with_total_bytes(RADROOTS_NIP01_COORDINATE_MAX_BYTES, index)
    750             })
    751             .collect::<Vec<_>>();
    752         overflow_targets.push(address_target_with_total_bytes(4_059, 31));
    753         assert_eq!(
    754             AuthoredNip09DeletionRequest::new("", Vec::new(), overflow_targets).unwrap_err(),
    755             Nip09DeletionError::TagBytesExceeded {
    756                 max: RADROOTS_NIP09_DELETION_TAG_TOTAL_MAX_BYTES,
    757                 actual: RADROOTS_NIP09_DELETION_TAG_TOTAL_MAX_BYTES + 1,
    758             }
    759         );
    760     }
    761 
    762     #[test]
    763     fn authored_request_counts_json_escaping_in_wire_budget() {
    764         let mut lower = 0usize;
    765         let mut upper = RADROOTS_NIP09_DELETION_CONTENT_MAX_BYTES;
    766         while lower < upper {
    767             let candidate = lower + (upper - lower).div_ceil(2);
    768             if AuthoredNip09DeletionRequest::new(
    769                 "\u{0001}".repeat(candidate),
    770                 vec![event_target('a', 1)],
    771                 Vec::new(),
    772             )
    773             .is_ok()
    774             {
    775                 lower = candidate;
    776             } else {
    777                 upper = candidate - 1;
    778             }
    779         }
    780         AuthoredNip09DeletionRequest::new(
    781             "\u{0001}".repeat(lower),
    782             vec![event_target('a', 1)],
    783             Vec::new(),
    784         )
    785         .expect("largest escaped content fitting the wire budget");
    786         assert!(matches!(
    787             AuthoredNip09DeletionRequest::new(
    788                 "\u{0001}".repeat(lower + 1),
    789                 vec![event_target('a', 1)],
    790                 Vec::new(),
    791             ),
    792             Err(Nip09DeletionError::EventWireTooLarge { max, .. })
    793                 if max == RADROOTS_NIP09_DELETION_EVENT_WIRE_MAX_BYTES
    794         ));
    795     }
    796 
    797     #[test]
    798     fn deletion_errors_expose_stable_codes_and_messages() {
    799         let errors = [
    800             Nip09DeletionError::ContentTooLarge { max: 1, actual: 2 },
    801             Nip09DeletionError::EventIdInvalid(ParseError::InvalidFormat),
    802             Nip09DeletionError::TargetKindOutOfRange { max: 1, actual: 2 },
    803             Nip09DeletionError::CoordinateInvalid(Nip01CoordinateParseError::InvalidFormat),
    804             Nip09DeletionError::DuplicateEventTarget {
    805                 event_id: "a".repeat(64),
    806             },
    807             Nip09DeletionError::DuplicateAddressTarget {
    808                 coordinate: format!("30000:{}:", "a".repeat(64)),
    809             },
    810             Nip09DeletionError::TargetMissing,
    811             Nip09DeletionError::TagCountExceeded { max: 1, actual: 2 },
    812             Nip09DeletionError::TagElementTooLarge { max: 1, actual: 2 },
    813             Nip09DeletionError::TagBytesExceeded { max: 1, actual: 2 },
    814             Nip09DeletionError::EventWireTooLarge { max: 1, actual: 2 },
    815         ];
    816         let expected = [
    817             "deletion_content_too_large",
    818             "deletion_event_target_invalid",
    819             "deletion_event_target_invalid",
    820             "deletion_address_target_invalid",
    821             "deletion_event_target_duplicate",
    822             "deletion_address_target_duplicate",
    823             "deletion_target_missing",
    824             "deletion_tag_count_exceeded",
    825             "deletion_tag_element_too_large",
    826             "deletion_tag_bytes_exceeded",
    827             "deletion_event_wire_too_large",
    828         ];
    829         for (error, expected) in errors.into_iter().zip(expected) {
    830             assert_eq!(error.code(), expected);
    831             assert!(!error.to_string().is_empty());
    832         }
    833     }
    834 }