lib

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

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 }