lib

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

authored.rs (9724B)


      1 #[cfg(not(feature = "std"))]
      2 use alloc::{string::String, string::ToString, vec, vec::Vec};
      3 
      4 use core::fmt;
      5 use radroots_event::{
      6     envelope::EventEnvelopeError,
      7     envelope::EventTags,
      8     envelope::kind::KIND_CLASSIFIED_LISTING,
      9     food::availability::{FoodAvailabilityDetails, FoodAvailabilityError},
     10     listing::classified::{TAG_RADROOTS_PRICE_UNIT, TAG_RADROOTS_QUANTITY},
     11     tag::name::{
     12         TAG_D, TAG_IMAGE, TAG_LOCATION, TAG_PRICE, TAG_PUBLISHED_AT, TAG_STATUS, TAG_SUMMARY,
     13         TAG_TITLE,
     14     },
     15     wire::{DEFAULT_RAW_JSON_MAX_BYTES, Nip01EventWireParts},
     16 };
     17 
     18 const FOOD_SIGNED_EVENT_FIXED_BYTES: usize = "{\"id\":\"".len()
     19     + 64
     20     + "\",\"pubkey\":\"".len()
     21     + 64
     22     + "\",\"created_at\":".len()
     23     + ",\"kind\":30402,\"tags\":".len()
     24     + ",\"content\":".len()
     25     + ",\"sig\":\"".len()
     26     + 128
     27     + "\"}".len();
     28 
     29 #[non_exhaustive]
     30 #[derive(Clone, Debug, PartialEq, Eq)]
     31 pub enum RadrootsFoodAvailabilityEncodeError {
     32     Domain(FoodAvailabilityError),
     33     Wire(EventEnvelopeError),
     34     EventWireTooLarge { max: usize, actual: usize },
     35 }
     36 
     37 impl RadrootsFoodAvailabilityEncodeError {
     38     pub const fn code(&self) -> &'static str {
     39         match self {
     40             Self::Domain(error) => error.code(),
     41             Self::Wire(EventEnvelopeError::TagElementTooLarge { .. }) => {
     42                 "food_tag_element_too_large"
     43             }
     44             Self::Wire(EventEnvelopeError::TagsTooLarge { .. }) => "food_tag_bytes_exceeded",
     45             Self::Wire(_) => "food_wire_invalid",
     46             Self::EventWireTooLarge { .. } => "food_event_wire_too_large",
     47         }
     48     }
     49 }
     50 
     51 impl fmt::Display for RadrootsFoodAvailabilityEncodeError {
     52     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     53         match self {
     54             Self::Domain(error) => write!(formatter, "{error}"),
     55             Self::Wire(error) => write!(formatter, "invalid FoodAvailability wire parts: {error}"),
     56             Self::EventWireTooLarge { max, actual } => write!(
     57                 formatter,
     58                 "FoodAvailability canonical signed event would be {actual} bytes; max is {max}"
     59             ),
     60         }
     61     }
     62 }
     63 
     64 #[cfg(feature = "std")]
     65 impl std::error::Error for RadrootsFoodAvailabilityEncodeError {
     66     fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
     67         match self {
     68             Self::Domain(error) => Some(error),
     69             Self::Wire(error) => Some(error),
     70             Self::EventWireTooLarge { .. } => None,
     71         }
     72     }
     73 }
     74 
     75 impl From<FoodAvailabilityError> for RadrootsFoodAvailabilityEncodeError {
     76     fn from(value: FoodAvailabilityError) -> Self {
     77         Self::Domain(value)
     78     }
     79 }
     80 
     81 impl From<EventEnvelopeError> for RadrootsFoodAvailabilityEncodeError {
     82     fn from(value: EventEnvelopeError) -> Self {
     83         Self::Wire(value)
     84     }
     85 }
     86 
     87 /// Builds exact canonical NIP-99 tags for strict FoodAvailability details.
     88 pub fn authored_food_availability_build_tags(
     89     details: &FoodAvailabilityDetails,
     90     created_at: u64,
     91 ) -> Result<Vec<Vec<String>>, RadrootsFoodAvailabilityEncodeError> {
     92     details.validate_created_at(created_at)?;
     93     let mut tags =
     94         Vec::with_capacity(8 + usize::from(details.quantity().is_some()) + details.images().len());
     95     tags.push(vec![TAG_D.into(), details.identifier().as_str().into()]);
     96     tags.push(vec![TAG_TITLE.into(), details.title().as_str().into()]);
     97     tags.push(vec![TAG_SUMMARY.into(), details.summary().as_str().into()]);
     98     tags.push(vec![
     99         TAG_PUBLISHED_AT.into(),
    100         details.published_at().to_string(),
    101     ]);
    102     tags.push(vec![
    103         TAG_LOCATION.into(),
    104         details.location().as_str().into(),
    105     ]);
    106     tags.push(vec![
    107         TAG_PRICE.into(),
    108         details.price().amount().into(),
    109         details.price().currency().as_str().into(),
    110     ]);
    111     tags.push(vec![
    112         TAG_RADROOTS_PRICE_UNIT.into(),
    113         details.price().unit().as_str().into(),
    114     ]);
    115     if let Some(quantity) = details.quantity() {
    116         tags.push(vec![
    117             TAG_RADROOTS_QUANTITY.into(),
    118             quantity.amount().into(),
    119             quantity.unit().as_str().into(),
    120         ]);
    121     }
    122     tags.push(vec![TAG_STATUS.into(), details.status().as_str().into()]);
    123     tags.extend(details.images().iter().map(|image| {
    124         vec![
    125             TAG_IMAGE.into(),
    126             image.url().into(),
    127             image.dimensions().to_string(),
    128         ]
    129     }));
    130 
    131     EventTags::new(tags.clone())?;
    132     Ok(tags)
    133 }
    134 
    135 /// Builds deterministic unsigned kind-30402 wire parts for strict FoodAvailability details.
    136 ///
    137 /// Every image is already byte-verified by the input typestate. A publication
    138 /// runtime must still prove BUD-02 upload completion before signing.
    139 pub fn authored_food_availability_to_wire_parts(
    140     details: &FoodAvailabilityDetails,
    141     created_at: u64,
    142 ) -> Result<Nip01EventWireParts, RadrootsFoodAvailabilityEncodeError> {
    143     let tags = authored_food_availability_build_tags(details, created_at)?;
    144     validate_compact_signed_wire_size(&tags, details.content().as_str(), created_at)?;
    145     Ok(Nip01EventWireParts {
    146         kind: KIND_CLASSIFIED_LISTING,
    147         content: details.content().as_str().into(),
    148         tags,
    149     })
    150 }
    151 
    152 fn validate_compact_signed_wire_size(
    153     tags: &[Vec<String>],
    154     content: &str,
    155     created_at: u64,
    156 ) -> Result<(), RadrootsFoodAvailabilityEncodeError> {
    157     let actual = canonical_food_signed_event_size(tags, content, created_at);
    158     if actual > DEFAULT_RAW_JSON_MAX_BYTES {
    159         return Err(RadrootsFoodAvailabilityEncodeError::EventWireTooLarge {
    160             max: DEFAULT_RAW_JSON_MAX_BYTES,
    161             actual,
    162         });
    163     }
    164     Ok(())
    165 }
    166 
    167 pub(crate) fn canonical_food_signed_event_size(
    168     tags: &[Vec<String>],
    169     content: &str,
    170     created_at: u64,
    171 ) -> usize {
    172     let mut tags_bytes = 2usize;
    173     for (tag_index, tag) in tags.iter().enumerate() {
    174         if tag_index > 0 {
    175             tags_bytes = tags_bytes.saturating_add(1);
    176         }
    177         tags_bytes = tags_bytes.saturating_add(2);
    178         for (element_index, element) in tag.iter().enumerate() {
    179             if element_index > 0 {
    180                 tags_bytes = tags_bytes.saturating_add(1);
    181             }
    182             tags_bytes = tags_bytes.saturating_add(canonical_json_string_bytes(element));
    183         }
    184     }
    185     FOOD_SIGNED_EVENT_FIXED_BYTES
    186         .saturating_add(decimal_u64_bytes(created_at))
    187         .saturating_add(tags_bytes)
    188         .saturating_add(canonical_json_string_bytes(content))
    189 }
    190 
    191 fn decimal_u64_bytes(mut value: u64) -> usize {
    192     let mut bytes = 1usize;
    193     while value >= 10 {
    194         value /= 10;
    195         bytes += 1;
    196     }
    197     bytes
    198 }
    199 
    200 fn canonical_json_string_bytes(value: &str) -> usize {
    201     value.chars().fold(2usize, |total, character| {
    202         total.saturating_add(match character {
    203             '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2,
    204             '\u{0000}'..='\u{001f}' => 6,
    205             _ => character.len_utf8(),
    206         })
    207     })
    208 }
    209 
    210 #[cfg(test)]
    211 mod tests {
    212     use super::*;
    213     use radroots_event::food::availability::{
    214         FoodAvailabilityDetailsParts, FoodAvailabilityStatus, FoodContent, FoodCurrency,
    215         FoodIdentifier, FoodPrice, FoodPublishedAt, FoodQuantity, FoodText, FoodUnit,
    216     };
    217 
    218     #[test]
    219     fn emits_the_exact_canonical_food_tag_order() {
    220         let details = details("Carrots available this week.");
    221         let expected: Vec<Vec<String>> = vec![
    222             vec!["d".into(), "nantes-carrots".into()],
    223             vec!["title".into(), "Nantes Carrots".into()],
    224             vec!["summary".into(), "Fresh bunches".into()],
    225             vec!["published_at".into(), "100".into()],
    226             vec!["location".into(), "Central Saanich, BC".into()],
    227             vec!["price".into(), "3".into(), "CAD".into()],
    228             vec!["radroots:price_unit".into(), "lb".into()],
    229             vec!["radroots:quantity".into(), "24".into(), "lb".into()],
    230             vec!["status".into(), "active".into()],
    231         ];
    232 
    233         assert_eq!(
    234             authored_food_availability_build_tags(&details, 200).unwrap(),
    235             expected
    236         );
    237         let parts = authored_food_availability_to_wire_parts(&details, 200).unwrap();
    238         assert_eq!(parts.kind, KIND_CLASSIFIED_LISTING);
    239         assert_eq!(parts.content, "Carrots available this week.");
    240     }
    241 
    242     #[test]
    243     fn rejects_future_publication_and_compact_wire_expansion() {
    244         assert_eq!(
    245             authored_food_availability_to_wire_parts(&details("Carrots"), 99)
    246                 .unwrap_err()
    247                 .code(),
    248             "food_published_at_future"
    249         );
    250 
    251         let escaped_content = core::iter::repeat_n('\0', 64 * 1024).collect::<String>();
    252         assert_eq!(
    253             authored_food_availability_to_wire_parts(&details(&escaped_content), 200)
    254                 .unwrap_err()
    255                 .code(),
    256             "food_event_wire_too_large"
    257         );
    258     }
    259 
    260     fn details(content: &str) -> FoodAvailabilityDetails {
    261         FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts {
    262             content: FoodContent::new(content).unwrap(),
    263             identifier: FoodIdentifier::parse("nantes-carrots").unwrap(),
    264             title: FoodText::new("Nantes Carrots").unwrap(),
    265             summary: FoodText::new("Fresh bunches").unwrap(),
    266             published_at: FoodPublishedAt::new(100).unwrap(),
    267             location: FoodText::new("Central Saanich, BC").unwrap(),
    268             price: FoodPrice::new("3", FoodCurrency::parse("CAD").unwrap(), FoodUnit::Pound)
    269                 .unwrap(),
    270             quantity: Some(FoodQuantity::new("24", FoodUnit::Pound).unwrap()),
    271             status: FoodAvailabilityStatus::Active,
    272             images: Vec::new(),
    273         })
    274         .unwrap()
    275     }
    276 }