mutation.rs (16427B)
1 //! Mutation draft preparation for Radroots Listing v1. 2 //! 3 //! Publish and update produce the stable public NIP-99 listing-kind event with 4 //! Radroots-specific JSON content, save-draft produces the stable listing-draft 5 //! event, and archive remains unsupported because Listing v1 has no archive 6 //! wire event. Strict NIP-99 Markdown-content interoperability is protocol-v2 7 //! work. 8 9 #![forbid(unsafe_code)] 10 11 #[cfg(all(feature = "serde_json", not(feature = "std")))] 12 use alloc::string::{String, ToString}; 13 14 #[cfg(all(feature = "serde_json", feature = "std"))] 15 use std::string::{String, ToString}; 16 17 use radroots_events::ids::RadrootsListingAddress; 18 #[cfg(feature = "serde_json")] 19 use radroots_events::{ 20 draft::{RadrootsDraftError, RadrootsFrozenEventDraft}, 21 kinds::{KIND_LISTING, KIND_LISTING_DRAFT}, 22 }; 23 #[cfg(feature = "serde_json")] 24 use radroots_events_codec::{listing::encode::to_wire_parts_with_kind, wire::to_frozen_draft}; 25 use thiserror::Error; 26 27 use crate::listing::draft::RadrootsCanonicalListingDraft; 28 29 /// Listing v1 mutation intent for draft preparation only. 30 /// 31 /// Publish and update target the public listing event, save-draft targets the 32 /// secret listing-draft event, and archive is intentionally unsupported because 33 /// listing v1 defines no archive wire event. 34 #[derive(Clone, Debug)] 35 pub enum RadrootsListingMutation { 36 Publish { 37 draft: RadrootsCanonicalListingDraft, 38 }, 39 Update { 40 draft: RadrootsCanonicalListingDraft, 41 }, 42 SaveDraft { 43 draft: RadrootsCanonicalListingDraft, 44 }, 45 Archive { 46 listing_addr: RadrootsListingAddress, 47 }, 48 } 49 50 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 51 pub enum RadrootsListingLifecycleState { 52 Draft, 53 Published, 54 } 55 56 #[derive(Clone, Debug, Error, PartialEq, Eq)] 57 pub enum RadrootsListingMutationError { 58 #[error("listing mutation is not supported")] 59 UnsupportedMutation, 60 #[cfg(feature = "serde_json")] 61 #[error("failed to encode listing mutation: {0}")] 62 EncodeListing(String), 63 #[cfg(feature = "serde_json")] 64 #[error("failed to build listing mutation draft: {0}")] 65 FrozenDraft(RadrootsDraftError), 66 } 67 68 const LISTING_PUBLISHED_CONTRACT_ID: &str = "radroots.listing.published.v1"; 69 const LISTING_DRAFT_CONTRACT_ID: &str = "radroots.listing.draft.v1"; 70 71 impl RadrootsListingMutation { 72 pub fn publish(draft: RadrootsCanonicalListingDraft) -> Self { 73 Self::Publish { draft } 74 } 75 76 pub fn update(draft: RadrootsCanonicalListingDraft) -> Self { 77 Self::Update { draft } 78 } 79 80 pub fn save_draft(draft: RadrootsCanonicalListingDraft) -> Self { 81 Self::SaveDraft { draft } 82 } 83 84 pub fn archive(listing_addr: RadrootsListingAddress) -> Self { 85 Self::Archive { listing_addr } 86 } 87 88 pub fn lifecycle_state( 89 &self, 90 ) -> Result<RadrootsListingLifecycleState, RadrootsListingMutationError> { 91 match self { 92 Self::Publish { .. } | Self::Update { .. } => { 93 Ok(RadrootsListingLifecycleState::Published) 94 } 95 Self::SaveDraft { .. } => Ok(RadrootsListingLifecycleState::Draft), 96 Self::Archive { .. } => Err(RadrootsListingMutationError::UnsupportedMutation), 97 } 98 } 99 100 pub fn canonical_draft( 101 &self, 102 ) -> Result<&RadrootsCanonicalListingDraft, RadrootsListingMutationError> { 103 match self { 104 Self::Publish { draft } | Self::Update { draft } | Self::SaveDraft { draft } => { 105 Ok(draft) 106 } 107 Self::Archive { .. } => Err(RadrootsListingMutationError::UnsupportedMutation), 108 } 109 } 110 111 pub fn listing_addr(&self) -> Result<&RadrootsListingAddress, RadrootsListingMutationError> { 112 match self { 113 Self::Publish { draft } | Self::Update { draft } => Ok(draft.public_listing_addr()), 114 Self::SaveDraft { draft } => Ok(draft.draft_listing_addr()), 115 Self::Archive { .. } => Err(RadrootsListingMutationError::UnsupportedMutation), 116 } 117 } 118 } 119 120 #[cfg(feature = "serde_json")] 121 pub fn build_listing_mutation_draft( 122 mutation: &RadrootsListingMutation, 123 created_at: u32, 124 ) -> Result<RadrootsFrozenEventDraft, RadrootsListingMutationError> { 125 let (draft, kind, contract_id) = match mutation { 126 RadrootsListingMutation::Publish { draft } | RadrootsListingMutation::Update { draft } => { 127 (draft, KIND_LISTING, LISTING_PUBLISHED_CONTRACT_ID) 128 } 129 RadrootsListingMutation::SaveDraft { draft } => { 130 (draft, KIND_LISTING_DRAFT, LISTING_DRAFT_CONTRACT_ID) 131 } 132 RadrootsListingMutation::Archive { .. } => { 133 return Err(RadrootsListingMutationError::UnsupportedMutation); 134 } 135 }; 136 let parts = to_wire_parts_with_kind(draft.listing(), kind) 137 .map_err(|error| RadrootsListingMutationError::EncodeListing(error.to_string()))?; 138 to_frozen_draft( 139 parts, 140 contract_id, 141 draft.seller_pubkey().as_str(), 142 created_at, 143 ) 144 .map_err(RadrootsListingMutationError::FrozenDraft) 145 } 146 147 #[cfg(test)] 148 mod tests { 149 use radroots_core::{ 150 RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, 151 RadrootsCoreQuantityPrice, RadrootsCoreUnit, 152 }; 153 use radroots_events::{ 154 RadrootsNostrEvent, 155 farm::RadrootsFarmRef, 156 ids::{RadrootsDTag, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsPublicKey}, 157 kinds::{KIND_LISTING, KIND_LISTING_DRAFT}, 158 listing::{ 159 RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, 160 RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct, 161 RadrootsListingStatus, 162 }, 163 resource_area::RadrootsResourceAreaRef, 164 }; 165 166 use crate::listing::draft::RadrootsCanonicalListingDraft; 167 use crate::listing::validation::validate_listing_event; 168 169 use super::{ 170 LISTING_DRAFT_CONTRACT_ID, LISTING_PUBLISHED_CONTRACT_ID, RadrootsListingLifecycleState, 171 RadrootsListingMutation, RadrootsListingMutationError, build_listing_mutation_draft, 172 }; 173 174 const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 175 176 fn d_tag(raw: &str) -> RadrootsDTag { 177 RadrootsDTag::parse(raw).expect("d tag") 178 } 179 180 fn bin_id(raw: &str) -> RadrootsInventoryBinId { 181 RadrootsInventoryBinId::parse(raw).expect("bin id") 182 } 183 184 fn listing() -> RadrootsListing { 185 RadrootsListing { 186 d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), 187 published_at: None, 188 farm: RadrootsFarmRef { 189 pubkey: SELLER.to_string(), 190 d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), 191 }, 192 product: RadrootsListingProduct { 193 key: "coffee".to_string(), 194 title: "Coffee".to_string(), 195 category: "coffee".to_string(), 196 summary: Some("Single origin coffee".to_string()), 197 process: None, 198 lot: None, 199 location: None, 200 profile: None, 201 year: None, 202 }, 203 primary_bin_id: bin_id("bin-1"), 204 bins: vec![RadrootsListingBin { 205 bin_id: bin_id("bin-1"), 206 quantity: RadrootsCoreQuantity::new( 207 RadrootsCoreDecimal::from(1000u32), 208 RadrootsCoreUnit::MassG, 209 ), 210 price_per_canonical_unit: RadrootsCoreQuantityPrice { 211 amount: RadrootsCoreMoney::new( 212 RadrootsCoreDecimal::from(20u32), 213 RadrootsCoreCurrency::USD, 214 ), 215 quantity: RadrootsCoreQuantity::new( 216 RadrootsCoreDecimal::from(1u32), 217 RadrootsCoreUnit::MassG, 218 ), 219 }, 220 display_amount: None, 221 display_unit: None, 222 display_label: None, 223 display_price: None, 224 display_price_unit: None, 225 }], 226 resource_area: None, 227 plot: None, 228 discounts: None, 229 inventory_available: Some(RadrootsCoreDecimal::from(5u32)), 230 availability: Some(RadrootsListingAvailability::Status { 231 status: RadrootsListingStatus::Active, 232 }), 233 delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), 234 location: Some(RadrootsListingLocation { 235 primary: "Farm".to_string(), 236 city: None, 237 region: None, 238 country: None, 239 lat: None, 240 lng: None, 241 geohash: None, 242 }), 243 images: None, 244 } 245 } 246 247 fn canonical_draft() -> RadrootsCanonicalListingDraft { 248 RadrootsCanonicalListingDraft::new( 249 listing(), 250 RadrootsPublicKey::parse(SELLER).expect("seller"), 251 ) 252 .expect("canonical listing draft") 253 } 254 255 #[test] 256 fn supported_mutations_report_lifecycle_states() { 257 assert_eq!( 258 RadrootsListingMutation::publish(canonical_draft()) 259 .lifecycle_state() 260 .expect("state"), 261 RadrootsListingLifecycleState::Published 262 ); 263 assert_eq!( 264 RadrootsListingMutation::update(canonical_draft()) 265 .lifecycle_state() 266 .expect("state"), 267 RadrootsListingLifecycleState::Published 268 ); 269 assert_eq!( 270 RadrootsListingMutation::save_draft(canonical_draft()) 271 .lifecycle_state() 272 .expect("state"), 273 RadrootsListingLifecycleState::Draft 274 ); 275 } 276 277 #[test] 278 fn supported_mutations_expose_canonical_drafts() { 279 let publish = RadrootsListingMutation::publish(canonical_draft()); 280 let update = RadrootsListingMutation::update(canonical_draft()); 281 let save_draft = RadrootsListingMutation::save_draft(canonical_draft()); 282 283 assert_eq!( 284 publish 285 .canonical_draft() 286 .expect("draft") 287 .seller_pubkey() 288 .as_str(), 289 SELLER 290 ); 291 assert_eq!( 292 update 293 .canonical_draft() 294 .expect("draft") 295 .seller_pubkey() 296 .as_str(), 297 SELLER 298 ); 299 assert_eq!( 300 save_draft 301 .canonical_draft() 302 .expect("draft") 303 .seller_pubkey() 304 .as_str(), 305 SELLER 306 ); 307 assert_eq!( 308 publish 309 .canonical_draft() 310 .expect("draft") 311 .listing() 312 .d_tag 313 .as_str(), 314 "AAAAAAAAAAAAAAAAAAAAAg" 315 ); 316 } 317 318 #[test] 319 fn supported_mutations_report_listing_addresses() { 320 let publish = RadrootsListingMutation::publish(canonical_draft()); 321 let update = RadrootsListingMutation::update(canonical_draft()); 322 let save_draft = RadrootsListingMutation::save_draft(canonical_draft()); 323 324 assert_eq!( 325 publish.listing_addr().expect("address").as_str(), 326 format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") 327 ); 328 assert_eq!( 329 update.listing_addr().expect("address").as_str(), 330 format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") 331 ); 332 assert_eq!( 333 save_draft.listing_addr().expect("address").as_str(), 334 format!("{KIND_LISTING_DRAFT}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") 335 ); 336 } 337 338 #[test] 339 fn archive_is_explicitly_unsupported() { 340 let archive = RadrootsListingMutation::archive( 341 RadrootsListingAddress::parse(format!( 342 "{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg" 343 )) 344 .expect("listing address"), 345 ); 346 347 assert_eq!( 348 archive.lifecycle_state().unwrap_err(), 349 RadrootsListingMutationError::UnsupportedMutation 350 ); 351 assert_eq!( 352 archive.canonical_draft().unwrap_err(), 353 RadrootsListingMutationError::UnsupportedMutation 354 ); 355 assert_eq!( 356 archive.listing_addr().unwrap_err(), 357 RadrootsListingMutationError::UnsupportedMutation 358 ); 359 } 360 361 #[test] 362 fn build_listing_mutation_draft_maps_publish_and_update_to_published_listing() { 363 let publish = RadrootsListingMutation::publish(canonical_draft()); 364 let update = RadrootsListingMutation::update(canonical_draft()); 365 366 let publish_draft = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft"); 367 let update_draft = build_listing_mutation_draft(&update, 1_700_000_000).expect("draft"); 368 369 assert_eq!(publish_draft.kind, KIND_LISTING); 370 assert_eq!(publish_draft.contract_id, LISTING_PUBLISHED_CONTRACT_ID); 371 assert_eq!(publish_draft.expected_pubkey, SELLER); 372 assert_eq!(publish_draft.created_at, 1_700_000_000); 373 assert_eq!(update_draft.kind, KIND_LISTING); 374 assert_eq!(update_draft.contract_id, LISTING_PUBLISHED_CONTRACT_ID); 375 assert_eq!(update_draft.expected_pubkey, SELLER); 376 } 377 378 #[test] 379 fn build_listing_mutation_draft_maps_save_draft_to_listing_draft() { 380 let save_draft = RadrootsListingMutation::save_draft(canonical_draft()); 381 382 let draft = build_listing_mutation_draft(&save_draft, 1_700_000_000).expect("draft"); 383 384 assert_eq!(draft.kind, KIND_LISTING_DRAFT); 385 assert_eq!(draft.contract_id, LISTING_DRAFT_CONTRACT_ID); 386 assert_eq!(draft.expected_pubkey, SELLER); 387 assert_eq!(draft.created_at, 1_700_000_000); 388 } 389 390 #[test] 391 fn build_listing_mutation_draft_rejects_archive() { 392 let archive = RadrootsListingMutation::archive( 393 RadrootsListingAddress::parse(format!( 394 "{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg" 395 )) 396 .expect("listing address"), 397 ); 398 399 assert_eq!( 400 build_listing_mutation_draft(&archive, 1_700_000_000).unwrap_err(), 401 RadrootsListingMutationError::UnsupportedMutation 402 ); 403 } 404 405 #[test] 406 fn build_listing_mutation_draft_reports_encode_errors() { 407 let mut listing = listing(); 408 listing.resource_area = Some(RadrootsResourceAreaRef { 409 pubkey: SELLER.to_string(), 410 d_tag: "bad d tag".to_string(), 411 }); 412 let draft = RadrootsCanonicalListingDraft::new( 413 listing, 414 RadrootsPublicKey::parse(SELLER).expect("seller"), 415 ) 416 .expect("canonical listing draft"); 417 let publish = RadrootsListingMutation::publish(draft); 418 419 let err = build_listing_mutation_draft(&publish, 1_700_000_000).unwrap_err(); 420 421 assert!(matches!( 422 err, 423 RadrootsListingMutationError::EncodeListing(_) 424 )); 425 } 426 427 #[test] 428 fn build_listing_mutation_draft_event_id_is_stable_for_fixed_input() { 429 let publish = RadrootsListingMutation::publish(canonical_draft()); 430 431 let first = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft"); 432 let second = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft"); 433 434 assert_eq!(first.expected_event_id, second.expected_event_id); 435 assert_eq!(first.expected_event_id.len(), 64); 436 assert_eq!(first.tags, second.tags); 437 assert_eq!(first.content, second.content); 438 } 439 440 #[test] 441 fn build_listing_mutation_draft_output_validates_as_trade_listing() { 442 let publish = RadrootsListingMutation::publish(canonical_draft()); 443 let draft = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft"); 444 445 let event = RadrootsNostrEvent { 446 id: String::new(), 447 author: draft.expected_pubkey.clone(), 448 created_at: draft.created_at, 449 kind: draft.kind, 450 tags: draft.tags, 451 content: draft.content, 452 sig: String::new(), 453 }; 454 let validated = validate_listing_event(&event).expect("validated listing"); 455 456 assert_eq!(validated.seller_pubkey, SELLER); 457 assert!(validated.listing_addr.contains(&format!(":{SELLER}:"))); 458 } 459 }