draft.rs (16692B)
1 //! Canonicalization for Radroots Listing v1 drafts. 2 //! 3 //! Listing v1 uses NIP-99 listing kind numbers and Radroots-specific JSON 4 //! content. Strict NIP-99 Markdown-content interoperability is protocol-v2 work. 5 //! Canonical drafts derive both addresses from the same seller pubkey and 6 //! d-tag: the public address is for publish or update intent, and the draft 7 //! address is for save-draft intent. 8 9 #![forbid(unsafe_code)] 10 11 #[cfg(not(feature = "std"))] 12 use alloc::{format, string::ToString, vec::Vec}; 13 14 #[cfg(feature = "std")] 15 use std::{string::ToString, vec::Vec}; 16 17 use radroots_authority::RadrootsActorContext; 18 use radroots_events::{ 19 contract::RadrootsActorRole, 20 ids::{ 21 RadrootsIdParseError, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsPublicKey, 22 }, 23 kinds::{KIND_LISTING, KIND_LISTING_DRAFT}, 24 listing::RadrootsListing, 25 }; 26 use thiserror::Error; 27 28 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 29 #[derive(Clone, Debug)] 30 pub struct RadrootsListingDraftDocumentV1 { 31 pub listing: RadrootsListing, 32 } 33 34 impl RadrootsListingDraftDocumentV1 { 35 pub fn new(listing: RadrootsListing) -> Self { 36 Self { listing } 37 } 38 } 39 40 #[cfg_attr(feature = "serde", derive(serde::Serialize))] 41 #[derive(Clone, Debug)] 42 pub struct RadrootsCanonicalListingDraft { 43 listing: RadrootsListing, 44 seller_pubkey: RadrootsPublicKey, 45 public_listing_addr: RadrootsListingAddress, 46 draft_listing_addr: RadrootsListingAddress, 47 } 48 49 impl RadrootsCanonicalListingDraft { 50 pub fn new( 51 mut listing: RadrootsListing, 52 seller_pubkey: RadrootsPublicKey, 53 ) -> Result<Self, RadrootsListingDraftError> { 54 let farm_pubkey = RadrootsPublicKey::parse(listing.farm.pubkey.as_str()) 55 .map_err(RadrootsListingDraftError::InvalidFarmPubkey)?; 56 if farm_pubkey != seller_pubkey { 57 return Err(RadrootsListingDraftError::FarmPubkeyMismatch { 58 expected_pubkey: seller_pubkey, 59 actual_pubkey: farm_pubkey, 60 }); 61 } 62 listing.farm.pubkey = farm_pubkey.as_str().to_string(); 63 validate_listing_bins(&listing)?; 64 65 let public_listing_addr = 66 listing_addr(KIND_LISTING, &seller_pubkey, listing.d_tag.as_str())?; 67 let draft_listing_addr = 68 listing_addr(KIND_LISTING_DRAFT, &seller_pubkey, listing.d_tag.as_str())?; 69 70 Ok(Self { 71 listing, 72 seller_pubkey, 73 public_listing_addr, 74 draft_listing_addr, 75 }) 76 } 77 78 pub fn listing(&self) -> &RadrootsListing { 79 &self.listing 80 } 81 82 pub fn seller_pubkey(&self) -> &RadrootsPublicKey { 83 &self.seller_pubkey 84 } 85 86 pub fn public_listing_addr(&self) -> &RadrootsListingAddress { 87 &self.public_listing_addr 88 } 89 90 pub fn draft_listing_addr(&self) -> &RadrootsListingAddress { 91 &self.draft_listing_addr 92 } 93 } 94 95 #[derive(Clone, Debug, Error, PartialEq, Eq)] 96 pub enum RadrootsListingDraftError { 97 #[error("invalid listing draft farm pubkey: {0}")] 98 InvalidFarmPubkey(RadrootsIdParseError), 99 #[error("invalid listing draft address: {0}")] 100 InvalidListingAddress(RadrootsIdParseError), 101 #[error("listing draft actor does not satisfy required role {required_role:?}")] 102 ActorRoleUnsatisfied { required_role: RadrootsActorRole }, 103 #[error("listing draft farm pubkey does not match seller")] 104 FarmPubkeyMismatch { 105 expected_pubkey: RadrootsPublicKey, 106 actual_pubkey: RadrootsPublicKey, 107 }, 108 #[error("listing draft primary bin is missing")] 109 MissingPrimaryBin { 110 primary_bin_id: RadrootsInventoryBinId, 111 }, 112 #[error("listing draft contains duplicate bin ID")] 113 DuplicateBinId { bin_id: RadrootsInventoryBinId }, 114 } 115 116 fn validate_listing_bins(listing: &RadrootsListing) -> Result<(), RadrootsListingDraftError> { 117 let primary_bin_id = listing.primary_bin_id.clone(); 118 let mut seen_bin_ids = Vec::new(); 119 let mut primary_bin_found = false; 120 for bin in &listing.bins { 121 if seen_bin_ids 122 .iter() 123 .any(|seen_bin_id| seen_bin_id == &bin.bin_id) 124 { 125 return Err(RadrootsListingDraftError::DuplicateBinId { 126 bin_id: bin.bin_id.clone(), 127 }); 128 } 129 if bin.bin_id == primary_bin_id { 130 primary_bin_found = true; 131 } 132 seen_bin_ids.push(bin.bin_id.clone()); 133 } 134 135 if !primary_bin_found { 136 return Err(RadrootsListingDraftError::MissingPrimaryBin { primary_bin_id }); 137 } 138 Ok(()) 139 } 140 141 fn listing_addr( 142 kind: u32, 143 seller_pubkey: &RadrootsPublicKey, 144 d_tag: &str, 145 ) -> Result<RadrootsListingAddress, RadrootsListingDraftError> { 146 RadrootsListingAddress::parse(format!("{kind}:{}:{d_tag}", seller_pubkey.as_str())) 147 .map_err(RadrootsListingDraftError::InvalidListingAddress) 148 } 149 150 pub fn canonicalize_listing_draft( 151 actor: &RadrootsActorContext, 152 mut document: RadrootsListingDraftDocumentV1, 153 ) -> Result<RadrootsCanonicalListingDraft, RadrootsListingDraftError> { 154 if !actor.satisfies(RadrootsActorRole::Seller) { 155 return Err(RadrootsListingDraftError::ActorRoleUnsatisfied { 156 required_role: RadrootsActorRole::Seller, 157 }); 158 } 159 160 let seller_pubkey = actor.pubkey().clone(); 161 let farm_pubkey = document.listing.farm.pubkey.as_str(); 162 if farm_pubkey.is_empty() { 163 document.listing.farm.pubkey = seller_pubkey.as_str().to_string(); 164 } 165 166 RadrootsCanonicalListingDraft::new(document.listing, seller_pubkey) 167 } 168 169 #[cfg(test)] 170 mod tests { 171 use radroots_authority::RadrootsActorContext; 172 use radroots_core::{ 173 RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, 174 RadrootsCoreQuantityPrice, RadrootsCoreUnit, 175 }; 176 use radroots_events::{ 177 contract::RadrootsActorRole, 178 farm::RadrootsFarmRef, 179 ids::{RadrootsDTag, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsPublicKey}, 180 kinds::{KIND_LISTING, KIND_LISTING_DRAFT}, 181 listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct}, 182 }; 183 184 use super::{ 185 RadrootsCanonicalListingDraft, RadrootsListingDraftDocumentV1, RadrootsListingDraftError, 186 canonicalize_listing_draft, 187 }; 188 189 const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 190 const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; 191 192 fn d_tag(raw: &str) -> RadrootsDTag { 193 RadrootsDTag::parse(raw).expect("d tag") 194 } 195 196 fn bin_id(raw: &str) -> RadrootsInventoryBinId { 197 RadrootsInventoryBinId::parse(raw).expect("bin id") 198 } 199 200 fn listing() -> RadrootsListing { 201 RadrootsListing { 202 d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), 203 published_at: None, 204 farm: RadrootsFarmRef { 205 pubkey: SELLER.to_string(), 206 d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), 207 }, 208 product: RadrootsListingProduct { 209 key: "coffee".to_string(), 210 title: "Coffee".to_string(), 211 category: "coffee".to_string(), 212 summary: Some("Single origin coffee".to_string()), 213 process: None, 214 lot: None, 215 location: None, 216 profile: None, 217 year: None, 218 }, 219 primary_bin_id: bin_id("bin-1"), 220 bins: vec![RadrootsListingBin { 221 bin_id: bin_id("bin-1"), 222 quantity: RadrootsCoreQuantity::new( 223 RadrootsCoreDecimal::from(1000u32), 224 RadrootsCoreUnit::MassG, 225 ), 226 price_per_canonical_unit: RadrootsCoreQuantityPrice { 227 amount: RadrootsCoreMoney::new( 228 RadrootsCoreDecimal::from(20u32), 229 RadrootsCoreCurrency::USD, 230 ), 231 quantity: RadrootsCoreQuantity::new( 232 RadrootsCoreDecimal::from(1u32), 233 RadrootsCoreUnit::MassG, 234 ), 235 }, 236 display_amount: None, 237 display_unit: None, 238 display_label: None, 239 display_price: None, 240 display_price_unit: None, 241 }], 242 resource_area: None, 243 plot: None, 244 discounts: None, 245 inventory_available: None, 246 availability: None, 247 delivery_method: None, 248 location: None, 249 images: None, 250 } 251 } 252 253 fn seller_actor() -> RadrootsActorContext { 254 RadrootsActorContext::explicit_pubkey(SELLER, [RadrootsActorRole::Seller]).expect("actor") 255 } 256 257 fn buyer_actor() -> RadrootsActorContext { 258 RadrootsActorContext::explicit_pubkey(SELLER, [RadrootsActorRole::Buyer]).expect("actor") 259 } 260 261 #[test] 262 fn draft_document_wraps_listing() { 263 let document = RadrootsListingDraftDocumentV1::new(listing()); 264 265 assert_eq!(document.listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); 266 assert_eq!(document.listing.product.title, "Coffee"); 267 } 268 269 #[cfg(feature = "serde_json")] 270 #[test] 271 fn draft_document_deserializes_as_untrusted_input() { 272 let json = serde_json::to_string(&RadrootsListingDraftDocumentV1::new(listing())) 273 .expect("serialize document"); 274 275 let document: RadrootsListingDraftDocumentV1 = 276 serde_json::from_str(&json).expect("deserialize document"); 277 let canonical = 278 canonicalize_listing_draft(&seller_actor(), document).expect("canonical draft"); 279 280 assert_eq!(canonical.seller_pubkey().as_str(), SELLER); 281 assert_eq!(canonical.listing().product.title, "Coffee"); 282 } 283 284 #[test] 285 fn canonical_draft_carries_seller_listing_and_addresses() { 286 let seller_pubkey = RadrootsPublicKey::parse(SELLER).expect("seller"); 287 let listing = listing(); 288 289 let canonical = 290 RadrootsCanonicalListingDraft::new(listing, seller_pubkey.clone()).expect("canonical"); 291 292 assert_eq!(canonical.seller_pubkey(), &seller_pubkey); 293 assert_eq!( 294 canonical.public_listing_addr().as_str(), 295 format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") 296 ); 297 assert_eq!( 298 canonical.draft_listing_addr().as_str(), 299 format!("{KIND_LISTING_DRAFT}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") 300 ); 301 assert_eq!(canonical.listing().d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); 302 } 303 304 #[test] 305 fn listing_draft_error_variants_are_precise() { 306 assert!(matches!( 307 RadrootsListingDraftError::InvalidFarmPubkey( 308 RadrootsPublicKey::parse("bad").unwrap_err() 309 ), 310 RadrootsListingDraftError::InvalidFarmPubkey(_) 311 )); 312 assert!(matches!( 313 RadrootsListingDraftError::InvalidListingAddress( 314 RadrootsListingAddress::parse("bad").unwrap_err() 315 ), 316 RadrootsListingDraftError::InvalidListingAddress(_) 317 )); 318 } 319 320 #[test] 321 fn canonicalize_listing_draft_fills_missing_farm_pubkey_and_derives_address() { 322 let mut listing = listing(); 323 listing.farm.pubkey.clear(); 324 let document = RadrootsListingDraftDocumentV1::new(listing); 325 326 let canonical = 327 canonicalize_listing_draft(&seller_actor(), document).expect("canonical draft"); 328 329 assert_eq!(canonical.seller_pubkey().as_str(), SELLER); 330 assert_eq!( 331 canonical.public_listing_addr().as_str(), 332 format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") 333 ); 334 assert_eq!( 335 canonical.draft_listing_addr().as_str(), 336 format!("{KIND_LISTING_DRAFT}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") 337 ); 338 assert_eq!(canonical.listing().farm.pubkey, SELLER); 339 } 340 341 #[test] 342 fn canonicalize_listing_draft_rejects_non_seller_actor() { 343 let document = RadrootsListingDraftDocumentV1::new(listing()); 344 345 let error = canonicalize_listing_draft(&buyer_actor(), document).unwrap_err(); 346 347 assert_eq!( 348 error, 349 RadrootsListingDraftError::ActorRoleUnsatisfied { 350 required_role: RadrootsActorRole::Seller 351 } 352 ); 353 } 354 355 #[test] 356 fn canonicalize_listing_draft_rejects_mismatched_farm_pubkey() { 357 let mut listing = listing(); 358 listing.farm.pubkey = OTHER.to_string(); 359 let document = RadrootsListingDraftDocumentV1::new(listing); 360 361 let error = canonicalize_listing_draft(&seller_actor(), document).unwrap_err(); 362 363 assert!(matches!( 364 error, 365 RadrootsListingDraftError::FarmPubkeyMismatch { .. } 366 )); 367 } 368 369 #[test] 370 fn canonicalize_listing_draft_rejects_invalid_farm_pubkey() { 371 let mut listing = listing(); 372 listing.farm.pubkey = "bad".to_string(); 373 let document = RadrootsListingDraftDocumentV1::new(listing); 374 375 let error = canonicalize_listing_draft(&seller_actor(), document).unwrap_err(); 376 377 assert!(matches!( 378 error, 379 RadrootsListingDraftError::InvalidFarmPubkey(_) 380 )); 381 } 382 383 #[test] 384 fn canonical_draft_new_rejects_mismatched_farm_pubkey() { 385 let mut listing = listing(); 386 listing.farm.pubkey = OTHER.to_string(); 387 388 let error = RadrootsCanonicalListingDraft::new( 389 listing, 390 RadrootsPublicKey::parse(SELLER).expect("seller"), 391 ) 392 .unwrap_err(); 393 394 assert!(matches!( 395 error, 396 RadrootsListingDraftError::FarmPubkeyMismatch { .. } 397 )); 398 } 399 400 #[test] 401 fn canonical_draft_new_rejects_invalid_farm_pubkey() { 402 let mut listing = listing(); 403 listing.farm.pubkey = "bad".to_string(); 404 405 let error = RadrootsCanonicalListingDraft::new( 406 listing, 407 RadrootsPublicKey::parse(SELLER).expect("seller"), 408 ) 409 .unwrap_err(); 410 411 assert!(matches!( 412 error, 413 RadrootsListingDraftError::InvalidFarmPubkey(_) 414 )); 415 } 416 417 #[test] 418 fn canonical_draft_new_rejects_empty_farm_pubkey() { 419 let mut listing = listing(); 420 listing.farm.pubkey.clear(); 421 422 let error = RadrootsCanonicalListingDraft::new( 423 listing, 424 RadrootsPublicKey::parse(SELLER).expect("seller"), 425 ) 426 .unwrap_err(); 427 428 assert!(matches!( 429 error, 430 RadrootsListingDraftError::InvalidFarmPubkey(_) 431 )); 432 } 433 434 #[test] 435 fn canonicalize_listing_draft_rejects_missing_primary_bin() { 436 let mut listing = listing(); 437 listing.primary_bin_id = bin_id("bin-2"); 438 let document = RadrootsListingDraftDocumentV1::new(listing); 439 440 let error = canonicalize_listing_draft(&seller_actor(), document).unwrap_err(); 441 442 assert_eq!( 443 error, 444 RadrootsListingDraftError::MissingPrimaryBin { 445 primary_bin_id: bin_id("bin-2") 446 } 447 ); 448 } 449 450 #[test] 451 fn canonical_draft_new_rejects_missing_primary_bin() { 452 let mut listing = listing(); 453 listing.primary_bin_id = bin_id("bin-2"); 454 455 let error = RadrootsCanonicalListingDraft::new( 456 listing, 457 RadrootsPublicKey::parse(SELLER).expect("seller"), 458 ) 459 .unwrap_err(); 460 461 assert_eq!( 462 error, 463 RadrootsListingDraftError::MissingPrimaryBin { 464 primary_bin_id: bin_id("bin-2") 465 } 466 ); 467 } 468 469 #[test] 470 fn canonicalize_listing_draft_rejects_duplicate_bin_ids() { 471 let mut listing = listing(); 472 listing.bins.push(listing.bins[0].clone()); 473 let document = RadrootsListingDraftDocumentV1::new(listing); 474 475 let error = canonicalize_listing_draft(&seller_actor(), document).unwrap_err(); 476 477 assert_eq!( 478 error, 479 RadrootsListingDraftError::DuplicateBinId { 480 bin_id: bin_id("bin-1") 481 } 482 ); 483 } 484 485 #[test] 486 fn canonical_draft_new_rejects_duplicate_bin_ids() { 487 let mut listing = listing(); 488 listing.bins.push(listing.bins[0].clone()); 489 490 let error = RadrootsCanonicalListingDraft::new( 491 listing, 492 RadrootsPublicKey::parse(SELLER).expect("seller"), 493 ) 494 .unwrap_err(); 495 496 assert_eq!( 497 error, 498 RadrootsListingDraftError::DuplicateBinId { 499 bin_id: bin_id("bin-1") 500 } 501 ); 502 } 503 }