mutation.rs (18897B)
1 //! Mutation plan preparation for Radroots Listing v1. 2 3 #![forbid(unsafe_code)] 4 5 use core::fmt; 6 7 use std::string::{String, ToString}; 8 9 use radroots_event::id::ClassifiedListingAddress; 10 use radroots_event::{ 11 GenericEventDraft, draft::DraftError, envelope::kind::KIND_CLASSIFIED_LISTING, 12 }; 13 use radroots_event_codec::{ 14 authoring::AuthoredEventPlan, encode::operational_listing::to_wire_parts_with_kind, 15 }; 16 17 use crate::listing::operational_listing::draft::RadrootsOperationalListingCanonicalEdit; 18 19 /// Listing v1 mutation intent for draft preparation only. 20 /// 21 /// Publish and update target the public listing event, while local-only draft 22 /// persistence and archive are intentionally unsupported as wire events. 23 #[derive(Clone, Debug)] 24 pub enum RadrootsOperationalListingMutation { 25 Publish { 26 draft: RadrootsOperationalListingCanonicalEdit, 27 }, 28 Update { 29 draft: RadrootsOperationalListingCanonicalEdit, 30 }, 31 SaveDraft { 32 draft: RadrootsOperationalListingCanonicalEdit, 33 }, 34 Archive { 35 listing_addr: ClassifiedListingAddress, 36 }, 37 } 38 39 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 40 pub enum RadrootsOperationalListingLifecycleState { 41 Draft, 42 Published, 43 } 44 45 #[derive(Clone, Debug, PartialEq, Eq)] 46 pub enum RadrootsOperationalListingMutationError { 47 UnsupportedMutation, 48 EncodeListing(String), 49 AuthoredPlan(DraftError), 50 } 51 52 impl fmt::Display for RadrootsOperationalListingMutationError { 53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 54 match self { 55 Self::UnsupportedMutation => f.write_str("listing mutation is not supported"), 56 Self::EncodeListing(error) => { 57 write!(f, "failed to encode listing mutation: {error}") 58 } 59 Self::AuthoredPlan(error) => { 60 write!(f, "failed to build listing authored plan: {error}") 61 } 62 } 63 } 64 } 65 66 impl core::error::Error for RadrootsOperationalListingMutationError {} 67 68 const OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID: &str = "radroots.operational_listing.published.v1"; 69 70 impl RadrootsOperationalListingMutation { 71 pub fn publish(draft: RadrootsOperationalListingCanonicalEdit) -> Self { 72 Self::Publish { draft } 73 } 74 75 pub fn update(draft: RadrootsOperationalListingCanonicalEdit) -> Self { 76 Self::Update { draft } 77 } 78 79 pub fn save_draft(draft: RadrootsOperationalListingCanonicalEdit) -> Self { 80 Self::SaveDraft { draft } 81 } 82 83 pub fn archive(listing_addr: ClassifiedListingAddress) -> Self { 84 Self::Archive { listing_addr } 85 } 86 87 pub fn lifecycle_state( 88 &self, 89 ) -> Result<RadrootsOperationalListingLifecycleState, RadrootsOperationalListingMutationError> 90 { 91 match self { 92 Self::Publish { .. } | Self::Update { .. } => { 93 Ok(RadrootsOperationalListingLifecycleState::Published) 94 } 95 Self::SaveDraft { .. } => Ok(RadrootsOperationalListingLifecycleState::Draft), 96 Self::Archive { .. } => { 97 Err(RadrootsOperationalListingMutationError::UnsupportedMutation) 98 } 99 } 100 } 101 102 pub fn canonical_draft( 103 &self, 104 ) -> Result<&RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingMutationError> 105 { 106 match self { 107 Self::Publish { draft } | Self::Update { draft } | Self::SaveDraft { draft } => { 108 Ok(draft) 109 } 110 Self::Archive { .. } => { 111 Err(RadrootsOperationalListingMutationError::UnsupportedMutation) 112 } 113 } 114 } 115 116 pub fn listing_addr( 117 &self, 118 ) -> Result<&ClassifiedListingAddress, RadrootsOperationalListingMutationError> { 119 match self { 120 Self::Publish { draft } | Self::Update { draft } => Ok(draft.public_listing_addr()), 121 Self::SaveDraft { draft } => Ok(draft.public_listing_addr()), 122 Self::Archive { .. } => { 123 Err(RadrootsOperationalListingMutationError::UnsupportedMutation) 124 } 125 } 126 } 127 } 128 129 pub fn build_operational_listing_mutation_plan( 130 mutation: &RadrootsOperationalListingMutation, 131 created_at: u64, 132 ) -> Result<AuthoredEventPlan, RadrootsOperationalListingMutationError> { 133 let (draft, kind, contract_id) = match mutation { 134 RadrootsOperationalListingMutation::Publish { draft } 135 | RadrootsOperationalListingMutation::Update { draft } => ( 136 draft, 137 KIND_CLASSIFIED_LISTING, 138 OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID, 139 ), 140 RadrootsOperationalListingMutation::SaveDraft { .. } 141 | RadrootsOperationalListingMutation::Archive { .. } => { 142 return Err(RadrootsOperationalListingMutationError::UnsupportedMutation); 143 } 144 }; 145 let parts = to_wire_parts_with_kind(draft.listing(), kind).map_err(|error| { 146 RadrootsOperationalListingMutationError::EncodeListing(error.to_string()) 147 })?; 148 AuthoredEventPlan::from_generic( 149 GenericEventDraft::new( 150 contract_id, 151 parts.kind, 152 created_at, 153 parts.tags, 154 parts.content, 155 draft.seller_pubkey().to_hex(), 156 ) 157 .map_err(RadrootsOperationalListingMutationError::AuthoredPlan)?, 158 ) 159 .map_err(RadrootsOperationalListingMutationError::AuthoredPlan) 160 } 161 162 #[cfg(all(test, feature = "local-signing"))] 163 mod tests { 164 const FIXTURE_ALICE_SECRET_KEY_HEX: &str = 165 "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; 166 const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = 167 "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; 168 use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; 169 use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; 170 use radroots_event::{ 171 contract::validate_event_contract_shape, 172 envelope::kind::KIND_CLASSIFIED_LISTING, 173 farm::FarmRef, 174 farm::resource_area::ResourceAreaRef, 175 id::{ClassifiedListingAddress, DTag, InventoryBinId}, 176 listing::operational::{ 177 OperationalListing, OperationalListingAvailability, OperationalListingBin, 178 OperationalListingDeliveryMethod, OperationalListingProduct, 179 OperationalListingPublicLocation, OperationalListingStatus, 180 }, 181 wire::Nip01EventWire, 182 }; 183 use radroots_event_codec::{authoring::AuthoredEventPlan, verify::verify_nip01_event}; 184 use radroots_identity::PublicKey; 185 186 use crate::listing::operational_listing::draft::RadrootsOperationalListingCanonicalEdit; 187 use crate::listing::operational_listing::validation::validate_operational_listing_event; 188 189 use super::{ 190 OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID, RadrootsOperationalListingLifecycleState, 191 RadrootsOperationalListingMutation, RadrootsOperationalListingMutationError, 192 build_operational_listing_mutation_plan, 193 }; 194 195 const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX; 196 197 fn d_tag(raw: &str) -> DTag { 198 DTag::parse(raw).expect("d tag") 199 } 200 201 fn bin_id(raw: &str) -> InventoryBinId { 202 InventoryBinId::parse(raw).expect("bin id") 203 } 204 205 fn sign_plan(plan: &AuthoredEventPlan) -> radroots_event::envelope::EventEnvelope { 206 let keys = Keys::parse(FIXTURE_ALICE_SECRET_KEY_HEX).expect("fixture signing key"); 207 assert_eq!(keys.public_key().to_hex(), plan.author().to_hex()); 208 let tags = plan 209 .body() 210 .tags() 211 .iter() 212 .cloned() 213 .map(|tag| Tag::parse(tag).expect("plan tag")) 214 .collect::<Vec<_>>(); 215 let event = EventBuilder::new( 216 Kind::Custom(u16::try_from(plan.body().kind()).expect("NIP-01 kind")), 217 plan.body().content(), 218 ) 219 .tags(tags) 220 .allow_self_tagging() 221 .custom_created_at(Timestamp::from_secs(plan.created_at())) 222 .sign_with_keys(&keys) 223 .expect("signed listing event"); 224 assert_eq!(event.id.to_hex(), plan.expected_event_id().to_hex()); 225 let raw_json = event.as_json(); 226 Nip01EventWire::parse_json(raw_json.as_str()) 227 .expect("canonical event wire") 228 .into_envelope() 229 .expect("event envelope") 230 } 231 232 fn listing() -> OperationalListing { 233 OperationalListing { 234 d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), 235 published_at: None, 236 farm: FarmRef { 237 pubkey: SELLER.to_string(), 238 d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), 239 }, 240 product: OperationalListingProduct { 241 key: "coffee".to_string(), 242 title: "Coffee".to_string(), 243 category: "coffee".to_string(), 244 summary: Some("Single origin coffee".to_string()), 245 process: None, 246 lot: None, 247 location: None, 248 profile: None, 249 year: None, 250 }, 251 primary_bin_id: bin_id("bin-1"), 252 bins: vec![OperationalListingBin { 253 bin_id: bin_id("bin-1"), 254 quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(), 255 price_per_canonical_unit: QuantityPrice::try_new( 256 Money::try_new(Decimal::from(20u32), Currency::USD).unwrap(), 257 Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(), 258 ) 259 .unwrap(), 260 display_amount: None, 261 display_unit: None, 262 display_label: None, 263 display_price: None, 264 display_price_unit: None, 265 }], 266 resource_area: None, 267 plot: None, 268 discounts: None, 269 inventory_available: Some(Decimal::from(5u32)), 270 availability: Some(OperationalListingAvailability::Status { 271 status: OperationalListingStatus::Active, 272 }), 273 delivery_method: Some(OperationalListingDeliveryMethod::Pickup), 274 location: Some(OperationalListingPublicLocation { 275 primary: "Farm".to_string(), 276 city: Some("Town".to_string()), 277 region: Some("Region".to_string()), 278 country: Some("US".to_string()), 279 geohash: "9q8yy".to_string(), 280 }), 281 images: None, 282 } 283 } 284 285 fn canonical_draft() -> RadrootsOperationalListingCanonicalEdit { 286 RadrootsOperationalListingCanonicalEdit::new( 287 listing(), 288 PublicKey::from_hex(SELLER).expect("seller"), 289 ) 290 .expect("canonical listing edit") 291 } 292 293 #[test] 294 fn supported_mutations_report_lifecycle_states() { 295 assert_eq!( 296 RadrootsOperationalListingMutation::publish(canonical_draft()) 297 .lifecycle_state() 298 .expect("state"), 299 RadrootsOperationalListingLifecycleState::Published 300 ); 301 assert_eq!( 302 RadrootsOperationalListingMutation::update(canonical_draft()) 303 .lifecycle_state() 304 .expect("state"), 305 RadrootsOperationalListingLifecycleState::Published 306 ); 307 assert_eq!( 308 RadrootsOperationalListingMutation::save_draft(canonical_draft()) 309 .lifecycle_state() 310 .expect("state"), 311 RadrootsOperationalListingLifecycleState::Draft 312 ); 313 } 314 315 #[test] 316 fn supported_mutations_expose_canonical_drafts() { 317 let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); 318 let update = RadrootsOperationalListingMutation::update(canonical_draft()); 319 let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft()); 320 321 assert_eq!( 322 publish 323 .canonical_draft() 324 .expect("draft") 325 .seller_pubkey() 326 .to_hex(), 327 SELLER 328 ); 329 assert_eq!( 330 update 331 .canonical_draft() 332 .expect("draft") 333 .seller_pubkey() 334 .to_hex(), 335 SELLER 336 ); 337 assert_eq!( 338 save_draft 339 .canonical_draft() 340 .expect("draft") 341 .seller_pubkey() 342 .to_hex(), 343 SELLER 344 ); 345 assert_eq!( 346 publish 347 .canonical_draft() 348 .expect("draft") 349 .listing() 350 .d_tag 351 .as_str(), 352 "AAAAAAAAAAAAAAAAAAAAAg" 353 ); 354 } 355 356 #[test] 357 fn supported_mutations_report_listing_addresses() { 358 let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); 359 let update = RadrootsOperationalListingMutation::update(canonical_draft()); 360 let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft()); 361 362 assert_eq!( 363 publish.listing_addr().expect("address").as_str(), 364 format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") 365 ); 366 assert_eq!( 367 update.listing_addr().expect("address").as_str(), 368 format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") 369 ); 370 assert_eq!( 371 save_draft.listing_addr().expect("address").as_str(), 372 format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") 373 ); 374 } 375 376 #[test] 377 fn archive_is_explicitly_unsupported() { 378 let archive = RadrootsOperationalListingMutation::archive( 379 ClassifiedListingAddress::parse(format!( 380 "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg" 381 )) 382 .expect("listing address"), 383 ); 384 385 assert_eq!( 386 archive.lifecycle_state().unwrap_err(), 387 RadrootsOperationalListingMutationError::UnsupportedMutation 388 ); 389 assert_eq!( 390 archive.canonical_draft().unwrap_err(), 391 RadrootsOperationalListingMutationError::UnsupportedMutation 392 ); 393 assert_eq!( 394 archive.listing_addr().unwrap_err(), 395 RadrootsOperationalListingMutationError::UnsupportedMutation 396 ); 397 } 398 399 #[test] 400 fn build_operational_listing_mutation_plan_maps_publish_and_update_to_published_listing() { 401 let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); 402 let update = RadrootsOperationalListingMutation::update(canonical_draft()); 403 404 let publish_draft = 405 build_operational_listing_mutation_plan(&publish, 1_700_000_000).expect("draft"); 406 let update_draft = 407 build_operational_listing_mutation_plan(&update, 1_700_000_000).expect("draft"); 408 409 assert_eq!(publish_draft.body().kind(), KIND_CLASSIFIED_LISTING); 410 assert_eq!( 411 publish_draft.body().contract().contract_id().as_str(), 412 OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID 413 ); 414 assert_eq!(publish_draft.author().to_hex(), SELLER); 415 assert_eq!(publish_draft.created_at(), 1_700_000_000); 416 assert_eq!( 417 publish_draft.body().content(), 418 "# Coffee\n\nSingle origin coffee" 419 ); 420 assert_eq!(update_draft.body().kind(), KIND_CLASSIFIED_LISTING); 421 assert_eq!( 422 update_draft.body().contract().contract_id().as_str(), 423 OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID 424 ); 425 assert_eq!(update_draft.author().to_hex(), SELLER); 426 } 427 428 #[test] 429 fn build_operational_listing_mutation_plan_rejects_save_draft() { 430 let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft()); 431 432 assert_eq!( 433 build_operational_listing_mutation_plan(&save_draft, 1_700_000_000).unwrap_err(), 434 RadrootsOperationalListingMutationError::UnsupportedMutation 435 ); 436 } 437 438 #[test] 439 fn build_operational_listing_mutation_plan_rejects_archive() { 440 let archive = RadrootsOperationalListingMutation::archive( 441 ClassifiedListingAddress::parse(format!( 442 "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg" 443 )) 444 .expect("listing address"), 445 ); 446 447 assert_eq!( 448 build_operational_listing_mutation_plan(&archive, 1_700_000_000).unwrap_err(), 449 RadrootsOperationalListingMutationError::UnsupportedMutation 450 ); 451 } 452 453 #[test] 454 fn build_operational_listing_mutation_plan_reports_encode_errors() { 455 let mut listing = listing(); 456 listing.resource_area = Some(ResourceAreaRef { 457 pubkey: SELLER.to_string(), 458 d_tag: "bad d tag".to_string(), 459 }); 460 let draft = RadrootsOperationalListingCanonicalEdit::new( 461 listing, 462 PublicKey::from_hex(SELLER).expect("seller"), 463 ) 464 .expect("canonical listing edit"); 465 let publish = RadrootsOperationalListingMutation::publish(draft); 466 467 let err = build_operational_listing_mutation_plan(&publish, 1_700_000_000).unwrap_err(); 468 469 assert!(matches!( 470 err, 471 RadrootsOperationalListingMutationError::EncodeListing(_) 472 )); 473 } 474 475 #[test] 476 fn build_operational_listing_mutation_plan_event_id_is_stable_for_fixed_input() { 477 let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); 478 479 let first = 480 build_operational_listing_mutation_plan(&publish, 1_700_000_000).expect("draft"); 481 let second = 482 build_operational_listing_mutation_plan(&publish, 1_700_000_000).expect("draft"); 483 484 assert_eq!(first.expected_event_id(), second.expected_event_id()); 485 assert_eq!(first.expected_event_id().to_hex().len(), 64); 486 assert_eq!(first.body().tags(), second.body().tags()); 487 assert_eq!(first.body().content(), second.body().content()); 488 } 489 490 #[test] 491 fn build_operational_listing_mutation_plan_output_validates_as_operational_listing() { 492 let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); 493 let draft = 494 build_operational_listing_mutation_plan(&publish, 1_700_000_000).expect("draft"); 495 496 let signed = sign_plan(&draft); 497 validate_event_contract_shape(&signed, OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID) 498 .expect("operational listing contract"); 499 let verified = verify_nip01_event(signed).expect("verified listing"); 500 let validated = validate_operational_listing_event(&verified).expect("validated listing"); 501 502 assert_eq!(validated.seller_pubkey, SELLER); 503 assert!( 504 validated 505 .listing_addr 506 .as_str() 507 .contains(&format!(":{SELLER}:")) 508 ); 509 } 510 }