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 }