lib

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

commit 0d77b3cc6925f76f51da83ce18c04846ee966ef7
parent ea66fdfb93519bb515195e0f14fd766a2388e84a
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Dec 2025 19:57:15 +0000

trade: add listing codec and DVM envelope types


- Add inventory/availability/delivery fields to listing model and tests
- Implement tag-based listing codec with optional JSON fast-path via serde_json feature
- Introduce trade listing DVM envelope, kinds, order/discount payloads, and validation
- Add nostr event conversion helpers and borrow-based client fetch API

Diffstat:
MCargo.lock | 1+
Mevents-codec/tests/listing.rs | 3+++
Mevents/src/listing.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mnostr/src/client.rs | 2+-
Mnostr/src/codec_adapters.rs | 12++++++------
Anostr/src/event_convert.rs | 25+++++++++++++++++++++++++
Anostr/src/job_adapter.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnostr/src/lib.rs | 12++++++++++++
Mtrade/Cargo.toml | 4+++-
Atrade/src/listing/codec.rs | 633+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atrade/src/listing/dvm.rs | 363+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atrade/src/listing/dvm_kinds.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtrade/src/listing/mod.rs | 5+++++
Atrade/src/listing/order.rs | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atrade/src/listing/validation.rs | 339+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
15 files changed, 1839 insertions(+), 10 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1897,6 +1897,7 @@ dependencies = [ "radroots-events", "radroots-events-codec", "serde", + "serde_json", "ts-rs", ] diff --git a/events-codec/tests/listing.rs b/events-codec/tests/listing.rs @@ -39,6 +39,9 @@ fn sample_listing(d_tag: &str) -> RadrootsListing { }], prices: vec![price], discounts: None, + inventory_available: None, + availability: None, + delivery_method: None, location: None, images: None, } diff --git a/events/src/listing.rs b/events/src/listing.rs @@ -1,6 +1,6 @@ use radroots_core::{ - RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCorePercent, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, + RadrootsCoreDecimal, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCorePercent, + RadrootsCoreQuantity, RadrootsCoreQuantityPrice, }; #[cfg(feature = "ts-rs")] use ts_rs::TS; @@ -34,6 +34,50 @@ pub struct RadrootsListingEventMetadata { #[cfg_attr(feature = "ts-rs", derive(TS))] #[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))] +#[derive(Clone, Debug)] +pub enum RadrootsListingAvailability { + Window { + #[cfg_attr(feature = "ts-rs", ts(optional, type = "number | null"))] + start: Option<u64>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "number | null"))] + end: Option<u64>, + }, + Status { + status: RadrootsListingStatus, + }, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))] +#[derive(Clone, Debug)] +pub enum RadrootsListingStatus { + Active, + Sold, + Other { + value: String, + }, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))] +#[derive(Clone, Debug)] +pub enum RadrootsListingDeliveryMethod { + Pickup, + LocalDelivery, + Shipping, + Other { + method: String, + }, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct RadrootsListing { pub d_tag: String, @@ -48,6 +92,21 @@ pub struct RadrootsListing { pub discounts: Option<Vec<RadrootsListingDiscount>>, #[cfg_attr( feature = "ts-rs", + ts(optional, type = "RadrootsCoreDecimal | null") + )] + pub inventory_available: Option<RadrootsCoreDecimal>, + #[cfg_attr( + feature = "ts-rs", + ts(optional, type = "RadrootsListingAvailability | null") + )] + pub availability: Option<RadrootsListingAvailability>, + #[cfg_attr( + feature = "ts-rs", + ts(optional, type = "RadrootsListingDeliveryMethod | null") + )] + pub delivery_method: Option<RadrootsListingDeliveryMethod>, + #[cfg_attr( + feature = "ts-rs", ts(optional, type = "RadrootsListingLocation | null") )] pub location: Option<RadrootsListingLocation>, diff --git a/nostr/src/client.rs b/nostr/src/client.rs @@ -11,7 +11,7 @@ pub async fn nostr_send_event( Ok(client.send_event_builder(event).await?) } -pub async fn nostr_fetch_event_by_id(client: Client, id: &str) -> Result<Event, NostrUtilsError> { +pub async fn nostr_fetch_event_by_id(client: &Client, id: &str) -> Result<Event, NostrUtilsError> { let event_id = EventId::parse(id)?; let filter = Filter::new().id(event_id); let events = client.fetch_events(filter, Duration::from_secs(10)).await?; diff --git a/nostr/src/codec_adapters.rs b/nostr/src/codec_adapters.rs @@ -39,7 +39,7 @@ fn sig_hex(e: &Event) -> String { pub fn to_job_request_metadata( e: &Event, -) -> Result<radroots_events::job::request::models::RadrootsJobRequestEventMetadata, JobParseError> { +) -> Result<radroots_events::job_request::RadrootsJobRequestEventMetadata, JobParseError> { req_decode::metadata_from_event( event_id(e), author(e), @@ -51,7 +51,7 @@ pub fn to_job_request_metadata( pub fn to_job_result_metadata( e: &Event, -) -> Result<radroots_events::job::result::models::RadrootsJobResultEventMetadata, JobParseError> { +) -> Result<radroots_events::job_result::RadrootsJobResultEventMetadata, JobParseError> { res_decode::metadata_from_event( event_id(e), author(e), @@ -64,7 +64,7 @@ pub fn to_job_result_metadata( pub fn to_job_feedback_metadata( e: &Event, -) -> Result<radroots_events::job::feedback::models::RadrootsJobFeedbackEventMetadata, JobParseError> +) -> Result<radroots_events::job_feedback::RadrootsJobFeedbackEventMetadata, JobParseError> { fb_decode::metadata_from_event( event_id(e), @@ -78,7 +78,7 @@ pub fn to_job_feedback_metadata( pub fn to_job_request_index( e: &Event, -) -> Result<radroots_events::job::request::models::RadrootsJobRequestEventIndex, JobParseError> { +) -> Result<radroots_events::job_request::RadrootsJobRequestEventIndex, JobParseError> { req_decode::index_from_event( event_id(e), author(e), @@ -92,7 +92,7 @@ pub fn to_job_request_index( pub fn to_job_result_index( e: &Event, -) -> Result<radroots_events::job::result::models::RadrootsJobResultEventIndex, JobParseError> { +) -> Result<radroots_events::job_result::RadrootsJobResultEventIndex, JobParseError> { res_decode::index_from_event( event_id(e), author(e), @@ -106,7 +106,7 @@ pub fn to_job_result_index( pub fn to_job_feedback_index( e: &Event, -) -> Result<radroots_events::job::feedback::models::RadrootsJobFeedbackEventIndex, JobParseError> { +) -> Result<radroots_events::job_feedback::RadrootsJobFeedbackEventIndex, JobParseError> { fb_decode::index_from_event( event_id(e), author(e), diff --git a/nostr/src/event_convert.rs b/nostr/src/event_convert.rs @@ -0,0 +1,25 @@ +#![forbid(unsafe_code)] + +use nostr::event::Event; +use radroots_events::{RadrootsNostrEvent, RadrootsNostrEventPtr}; + +use crate::util::event_created_at_u32_saturating; + +pub fn radroots_event_from_nostr(event: &Event) -> RadrootsNostrEvent { + RadrootsNostrEvent { + id: event.id.to_string(), + author: event.pubkey.to_string(), + created_at: event_created_at_u32_saturating(event), + kind: event.kind.as_u16() as u32, + tags: event.tags.iter().map(|t| t.as_slice().to_vec()).collect(), + content: event.content.clone(), + sig: event.sig.to_string(), + } +} + +pub fn radroots_event_ptr_from_nostr(event: &Event) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: event.id.to_string(), + relays: None, + } +} diff --git a/nostr/src/job_adapter.rs b/nostr/src/job_adapter.rs @@ -0,0 +1,80 @@ +#![forbid(unsafe_code)] + +use nostr::event::Event; +use radroots_events_codec::job::traits::{JobEventBorrow, JobEventLike}; + +#[derive(Clone, Debug)] +pub struct NostrEventAdapter<'a> { + evt: &'a Event, + id_hex: String, + author_hex: String, +} + +impl<'a> NostrEventAdapter<'a> { + #[inline] + pub fn new(evt: &'a Event) -> Self { + Self { + evt, + id_hex: evt.id.to_hex(), + author_hex: evt.pubkey.to_string(), + } + } + + #[inline] + fn tags_as_slices(&self) -> Vec<Vec<String>> { + self.evt + .tags + .iter() + .map(|t| t.as_slice().to_vec()) + .collect() + } +} + +impl<'a> JobEventBorrow<'a> for NostrEventAdapter<'a> { + #[inline] + fn raw_id(&'a self) -> &'a str { + &self.id_hex + } + #[inline] + fn raw_author(&'a self) -> &'a str { + &self.author_hex + } + #[inline] + fn raw_content(&'a self) -> &'a str { + &self.evt.content + } + #[inline] + fn raw_kind(&'a self) -> u32 { + match self.evt.kind { + nostr::event::Kind::Custom(v) => v as u32, + _ => 0, + } + } +} + +impl JobEventLike for NostrEventAdapter<'_> { + fn raw_id(&self) -> String { + self.id_hex.clone() + } + fn raw_author(&self) -> String { + self.author_hex.clone() + } + fn raw_published_at(&self) -> u32 { + self.evt.created_at.as_u64() as u32 + } + fn raw_kind(&self) -> u32 { + match self.evt.kind { + nostr::event::Kind::Custom(v) => v as u32, + _ => 0, + } + } + fn raw_content(&self) -> String { + self.evt.content.clone() + } + fn raw_tags(&self) -> Vec<Vec<String>> { + self.tags_as_slices() + } + fn raw_sig(&self) -> String { + self.evt.sig.to_string() + } +} diff --git a/nostr/src/lib.rs b/nostr/src/lib.rs @@ -16,12 +16,18 @@ pub mod util; #[cfg(feature = "codec")] pub mod codec_adapters; +#[cfg(feature = "codec")] +pub mod job_adapter; + #[cfg(feature = "http")] pub mod nip11; #[cfg(feature = "events")] pub mod event_adapters; +#[cfg(feature = "events")] +pub mod event_convert; + pub mod prelude { pub use crate::events::build_nostr_event; @@ -50,4 +56,10 @@ pub mod prelude { #[cfg(feature = "events")] pub use crate::event_adapters::{to_post_event_metadata, to_profile_event_metadata}; + + #[cfg(feature = "events")] + pub use crate::event_convert::{radroots_event_from_nostr, radroots_event_ptr_from_nostr}; + + #[cfg(feature = "codec")] + pub use crate::job_adapter::NostrEventAdapter; } diff --git a/trade/Cargo.toml b/trade/Cargo.toml @@ -8,9 +8,10 @@ license.workspace = true build = "build.rs" [features] -default = ["std", "serde", "ts-rs"] +default = ["std", "serde", "serde_json", "ts-rs"] std = [] serde = ["dep:serde", "radroots-core/serde", "radroots-events/serde", "radroots-events-codec/serde"] +serde_json = ["serde", "dep:serde_json"] ts-rs = ["dep:ts-rs"] [dependencies] @@ -18,4 +19,5 @@ radroots-core = { workspace = true, default-features = false } radroots-events = { workspace = true, default-features = false } radroots-events-codec = { workspace = true, default-features = false } serde = { workspace = true, default-features = false, features = ["alloc", "derive"], optional = true } +serde_json = { workspace = true, default-features = false, features = ["alloc"], optional = true } ts-rs = { workspace = true, optional = true } diff --git a/trade/src/listing/codec.rs b/trade/src/listing/codec.rs @@ -0,0 +1,633 @@ +#![forbid(unsafe_code)] + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit}; +use radroots_events::listing::{ + RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, + RadrootsListingDiscount, RadrootsListingImage, RadrootsListingImageSize, RadrootsListingLocation, + RadrootsListingProduct, RadrootsListingQuantity, RadrootsListingStatus, +}; +use radroots_events::tags::TAG_D; +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +const TAG_QUANTITY: &str = "quantity"; +const TAG_PRICE: &str = "price"; +const TAG_PRICE_DISCOUNT_PREFIX: &str = "price-discount-"; +const TAG_LOCATION: &str = "location"; +const TAG_IMAGE: &str = "image"; +const TAG_GEOHASH: &str = "g"; +const TAG_INVENTORY: &str = "inventory"; +const TAG_DELIVERY: &str = "delivery"; +const TAG_PUBLISHED_AT: &str = "published_at"; +const TAG_STATUS: &str = "status"; +const TAG_EXPIRES_AT: &str = "expires_at"; + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TradeListingParseError { + MissingTag(String), + InvalidTag(String), + InvalidNumber(String), + InvalidUnit, + InvalidCurrency, + InvalidJson(String), + InvalidDiscount(String), +} + +impl core::fmt::Display for TradeListingParseError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + TradeListingParseError::MissingTag(tag) => write!(f, "missing required tag: {tag}"), + TradeListingParseError::InvalidTag(tag) => write!(f, "invalid tag: {tag}"), + TradeListingParseError::InvalidNumber(field) => write!(f, "invalid number: {field}"), + TradeListingParseError::InvalidUnit => write!(f, "invalid unit"), + TradeListingParseError::InvalidCurrency => write!(f, "invalid currency"), + TradeListingParseError::InvalidJson(field) => write!(f, "invalid json: {field}"), + TradeListingParseError::InvalidDiscount(kind) => { + write!(f, "invalid discount data for {kind}") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for TradeListingParseError {} + +fn parse_decimal(s: &str, field: &str) -> Result<RadrootsCoreDecimal, TradeListingParseError> { + s.parse::<RadrootsCoreDecimal>() + .map_err(|_| TradeListingParseError::InvalidNumber(field.to_string())) +} + +fn parse_currency(s: &str) -> Result<RadrootsCoreCurrency, TradeListingParseError> { + let upper = s.trim().to_ascii_uppercase(); + RadrootsCoreCurrency::from_str_upper(&upper) + .map_err(|_| TradeListingParseError::InvalidCurrency) +} + +fn parse_unit(s: &str) -> Result<RadrootsCoreUnit, TradeListingParseError> { + s.parse::<RadrootsCoreUnit>() + .map_err(|_| TradeListingParseError::InvalidUnit) +} + +fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, TradeListingParseError> { + let tag = tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_D)) + .ok_or_else(|| TradeListingParseError::MissingTag(TAG_D.to_string()))?; + let value = tag + .get(1) + .map(|s| s.to_string()) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_D.to_string()))?; + if value.trim().is_empty() { + return Err(TradeListingParseError::InvalidTag(TAG_D.to_string())); + } + Ok(value) +} + +pub fn listing_from_event_parts( + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsListing, TradeListingParseError> { + let d_tag = parse_d_tag(tags)?; + + if !content.trim().is_empty() { + #[cfg(feature = "serde_json")] + { + if let Ok(mut listing) = serde_json::from_str::<RadrootsListing>(content) { + if listing.d_tag.trim().is_empty() { + listing.d_tag = d_tag; + } else if listing.d_tag != d_tag { + return Err(TradeListingParseError::InvalidTag(TAG_D.to_string())); + } + return Ok(listing); + } + } + } + + listing_from_tags(tags, d_tag) +} + +pub fn listing_tags_build(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, TradeListingParseError> { + let d_tag = listing.d_tag.trim(); + if d_tag.is_empty() { + return Err(TradeListingParseError::MissingTag(TAG_D.to_string())); + } + + let mut tags: Vec<Vec<String>> = Vec::new(); + tags.push(vec![TAG_D.to_string(), d_tag.to_string()]); + + let product = &listing.product; + push_tag_value(&mut tags, "key", &product.key); + push_tag_value(&mut tags, "title", &product.title); + push_tag_value(&mut tags, "category", &product.category); + if let Some(summary) = &product.summary { + push_tag_value(&mut tags, "summary", summary); + } + if let Some(process) = &product.process { + push_tag_value(&mut tags, "process", process); + } + if let Some(lot) = &product.lot { + push_tag_value(&mut tags, "lot", lot); + } + if let Some(profile) = &product.profile { + push_tag_value(&mut tags, "profile", profile); + } + if let Some(year) = &product.year { + push_tag_value(&mut tags, "year", year); + } + + for quantity in &listing.quantities { + let mut tag = Vec::with_capacity(5); + tag.push(TAG_QUANTITY.to_string()); + tag.push(quantity.value.amount.to_string()); + tag.push(quantity.value.unit.code().to_string()); + if let Some(label) = quantity.label.as_ref().and_then(|v| clean_value(v)) { + tag.push(label); + } + if let Some(count) = quantity.count { + tag.push(count.to_string()); + } + tags.push(tag); + } + + for price in &listing.prices { + let mut tag = Vec::with_capacity(6); + tag.push(TAG_PRICE.to_string()); + tag.push(price.amount.amount.to_string()); + tag.push(price.amount.currency.to_string().to_ascii_lowercase()); + tag.push(price.quantity.amount.to_string()); + tag.push(price.quantity.unit.code().to_string()); + if let Some(label) = price.quantity.label.as_ref().and_then(|v| clean_value(v)) { + tag.push(label); + } + tags.push(tag); + } + + if let Some(discounts) = &listing.discounts { + for discount in discounts { + let (kind, payload) = discount_to_tag_parts(discount)?; + tags.push(vec![format!("{TAG_PRICE_DISCOUNT_PREFIX}{kind}"), payload]); + } + } + + if let Some(inventory) = &listing.inventory_available { + tags.push(vec![TAG_INVENTORY.to_string(), inventory.to_string()]); + } + + if let Some(availability) = &listing.availability { + match availability { + RadrootsListingAvailability::Status { status } => { + tags.push(vec![TAG_STATUS.to_string(), status_as_str(status).to_string()]); + } + RadrootsListingAvailability::Window { start, end } => { + if let Some(start) = start { + tags.push(vec![TAG_PUBLISHED_AT.to_string(), start.to_string()]); + } + if let Some(end) = end { + tags.push(vec![TAG_EXPIRES_AT.to_string(), end.to_string()]); + } + } + } + } + + if let Some(method) = &listing.delivery_method { + let mut tag = Vec::with_capacity(3); + tag.push(TAG_DELIVERY.to_string()); + match method { + RadrootsListingDeliveryMethod::Pickup => tag.push("pickup".into()), + RadrootsListingDeliveryMethod::LocalDelivery => tag.push("local_delivery".into()), + RadrootsListingDeliveryMethod::Shipping => tag.push("shipping".into()), + RadrootsListingDeliveryMethod::Other { method } => { + tag.push("other".into()); + tag.push(method.clone()); + } + } + tags.push(tag); + } + + if let Some(location) = &listing.location { + let mut tag = Vec::with_capacity(5); + tag.push(TAG_LOCATION.to_string()); + tag.push(location.primary.clone()); + if let Some(city) = location.city.as_ref().and_then(|v| clean_value(v)) { + tag.push(city); + } + if let Some(region) = location.region.as_ref().and_then(|v| clean_value(v)) { + tag.push(region); + } + if let Some(country) = location.country.as_ref().and_then(|v| clean_value(v)) { + tag.push(country); + } + tags.push(tag); + if let Some(geohash) = location.geohash.as_ref().and_then(|v| clean_value(v)) { + tags.push(vec![TAG_GEOHASH.to_string(), geohash]); + } + } + + if let Some(images) = &listing.images { + for image in images { + if image.url.trim().is_empty() { + continue; + } + let mut tag = Vec::with_capacity(3); + tag.push(TAG_IMAGE.to_string()); + tag.push(image.url.clone()); + if let Some(size) = &image.size { + tag.push(format!("{}x{}", size.w, size.h)); + } + tags.push(tag); + } + } + + Ok(tags) +} + +fn listing_from_tags( + tags: &[Vec<String>], + d_tag: String, +) -> Result<RadrootsListing, TradeListingParseError> { + let mut product = RadrootsListingProduct { + key: String::new(), + title: String::new(), + category: String::new(), + summary: None, + process: None, + lot: None, + location: None, + profile: None, + year: None, + }; + + let mut quantities: Vec<RadrootsListingQuantity> = Vec::new(); + let mut prices: Vec<RadrootsCoreQuantityPrice> = Vec::new(); + let mut discounts: Vec<RadrootsListingDiscount> = Vec::new(); + let mut location: Option<RadrootsListingLocation> = None; + let mut inventory_available: Option<RadrootsCoreDecimal> = None; + let mut availability_status: Option<RadrootsListingStatus> = None; + let mut availability_start: Option<u64> = None; + let mut availability_end: Option<u64> = None; + let mut delivery_method: Option<RadrootsListingDeliveryMethod> = None; + let mut images: Vec<RadrootsListingImage> = Vec::new(); + let mut geohash: Option<String> = None; + + let has_structured_location = tags.iter().any(|tag| { + tag.get(0).map(|k| k.as_str()) == Some(TAG_LOCATION) && tag.len() >= 3 + }); + + for tag in tags { + if tag.is_empty() { + continue; + } + let key = tag[0].as_str(); + match key { + "key" => set_if_empty(&mut product.key, tag.get(1)), + "title" => set_if_empty(&mut product.title, tag.get(1)), + "category" => set_if_empty(&mut product.category, tag.get(1)), + "summary" => set_optional(&mut product.summary, tag.get(1)), + "process" => set_optional(&mut product.process, tag.get(1)), + "lot" => set_optional(&mut product.lot, tag.get(1)), + "location" => { + if tag.len() >= 3 + || (!has_structured_location && location.is_none() && tag.len() >= 2) + { + let primary = + tag.get(1).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_LOCATION.to_string()))?; + if primary.trim().is_empty() { + return Err(TradeListingParseError::InvalidTag(TAG_LOCATION.to_string())); + } + let mut loc = RadrootsListingLocation { + primary: primary.to_string(), + city: None, + region: None, + country: None, + lat: None, + lng: None, + geohash: None, + }; + if let Some(city) = tag.get(2).and_then(|v| clean_value(v)) { + loc.city = Some(city); + } + if let Some(region) = tag.get(3).and_then(|v| clean_value(v)) { + loc.region = Some(region); + } + if let Some(country) = tag.get(4).and_then(|v| clean_value(v)) { + loc.country = Some(country); + } + location = Some(loc); + } else { + set_optional(&mut product.location, tag.get(1)); + } + } + "profile" => set_optional(&mut product.profile, tag.get(1)), + "year" => set_optional(&mut product.year, tag.get(1)), + TAG_QUANTITY => { + let amount = tag.get(1).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_QUANTITY.to_string()))?; + let unit = tag.get(2).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_QUANTITY.to_string()))?; + let amount = parse_decimal(amount, TAG_QUANTITY)?; + let unit = parse_unit(unit)?; + let label = tag.get(3).and_then(|v| clean_value(v)); + let count = tag.get(4).and_then(|v| v.parse::<u32>().ok()); + quantities.push(RadrootsListingQuantity { + value: RadrootsCoreQuantity::new(amount, unit), + label, + count, + }); + } + TAG_PRICE => { + let amount = tag.get(1).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_PRICE.to_string()))?; + let currency = tag.get(2).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_PRICE.to_string()))?; + if tag.len() >= 5 { + let quantity_amount = tag.get(3).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_PRICE.to_string()))?; + let unit = tag.get(4).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_PRICE.to_string()))?; + let amount = parse_decimal(amount, TAG_PRICE)?; + let currency = parse_currency(currency)?; + let quantity_amount = parse_decimal(quantity_amount, TAG_PRICE)?; + let unit = parse_unit(unit)?; + let label = tag.get(5).and_then(|v| clean_value(v)); + let quantity = RadrootsCoreQuantity::new(quantity_amount, unit).with_optional_label(label); + prices.push(RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new(amount, currency), + quantity, + }); + } else { + let amount = parse_decimal(amount, TAG_PRICE)?; + let currency = parse_currency(currency)?; + let quantity = RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); + prices.push(RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new(amount, currency), + quantity, + }); + } + } + TAG_GEOHASH => { + if let Some(value) = tag.get(1).and_then(|v| clean_value(v)) { + geohash = Some(value); + } + } + TAG_INVENTORY => { + let value = tag.get(1).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_INVENTORY.to_string()))?; + inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?); + } + TAG_PUBLISHED_AT => { + let value = tag.get(1).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_PUBLISHED_AT.to_string()))?; + availability_start = Some( + value + .parse::<u64>() + .map_err(|_| TradeListingParseError::InvalidNumber(TAG_PUBLISHED_AT.to_string()))?, + ); + } + TAG_EXPIRES_AT => { + let value = tag.get(1).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_EXPIRES_AT.to_string()))?; + availability_end = Some( + value + .parse::<u64>() + .map_err(|_| TradeListingParseError::InvalidNumber(TAG_EXPIRES_AT.to_string()))?, + ); + } + TAG_STATUS => { + let status = tag.get(1).and_then(|v| clean_value(v)).unwrap_or_default(); + availability_status = Some(parse_status(&status)); + } + TAG_DELIVERY => { + let method = tag.get(1).and_then(|v| clean_value(v)).unwrap_or_default(); + delivery_method = Some(match method.as_str() { + "pickup" => RadrootsListingDeliveryMethod::Pickup, + "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery, + "shipping" => RadrootsListingDeliveryMethod::Shipping, + "other" => { + let detail = tag.get(2).and_then(|v| clean_value(v)).unwrap_or_default(); + RadrootsListingDeliveryMethod::Other { method: detail } + } + other => RadrootsListingDeliveryMethod::Other { + method: other.to_string(), + }, + }); + } + TAG_IMAGE => { + let url = tag.get(1).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_IMAGE.to_string()))?; + if url.trim().is_empty() { + continue; + } + let size = tag.get(2).and_then(|s| parse_image_size(s)); + images.push(RadrootsListingImage { + url: url.to_string(), + size, + }); + } + _ if key.starts_with(TAG_PRICE_DISCOUNT_PREFIX) => { + let kind = key.trim_start_matches(TAG_PRICE_DISCOUNT_PREFIX); + let payload = tag.get(1).ok_or_else(|| TradeListingParseError::InvalidDiscount(kind.to_string()))?; + let discount = parse_discount(kind, payload)?; + discounts.push(discount); + } + _ => {} + } + } + + let availability = match availability_status { + Some(status) => Some(RadrootsListingAvailability::Status { status }), + None => match (availability_start, availability_end) { + (None, None) => None, + (start, end) => Some(RadrootsListingAvailability::Window { start, end }), + }, + }; + + let location = location.map(|mut loc| { + if loc.geohash.is_none() { + loc.geohash = geohash; + } + loc + }); + + Ok(RadrootsListing { + d_tag, + product, + quantities, + prices, + discounts: if discounts.is_empty() { None } else { Some(discounts) }, + inventory_available, + availability, + delivery_method, + location, + images: if images.is_empty() { None } else { Some(images) }, + }) +} + +fn push_tag_value(tags: &mut Vec<Vec<String>>, key: &str, value: &str) { + if let Some(cleaned) = clean_value(value) { + tags.push(vec![key.to_string(), cleaned]); + } +} + +fn clean_value(value: &str) -> Option<String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn set_if_empty(target: &mut String, value: Option<&String>) { + if target.trim().is_empty() { + if let Some(v) = value.and_then(|v| clean_value(v)) { + *target = v; + } + } +} + +fn set_optional(target: &mut Option<String>, value: Option<&String>) { + if target.is_none() { + if let Some(v) = value.and_then(|v| clean_value(v)) { + *target = Some(v); + } + } +} + +fn parse_status(value: &str) -> RadrootsListingStatus { + match value.trim().to_ascii_lowercase().as_str() { + "active" => RadrootsListingStatus::Active, + "sold" => RadrootsListingStatus::Sold, + other => RadrootsListingStatus::Other { + value: other.to_string(), + }, + } +} + +fn status_as_str(status: &RadrootsListingStatus) -> &str { + match status { + RadrootsListingStatus::Active => "active", + RadrootsListingStatus::Sold => "sold", + RadrootsListingStatus::Other { value } => value.as_str(), + } +} + +fn parse_image_size(value: &str) -> Option<RadrootsListingImageSize> { + let mut parts = value.split('x'); + let w = parts.next()?.parse::<u32>().ok()?; + let h = parts.next()?.parse::<u32>().ok()?; + Some(RadrootsListingImageSize { w, h }) +} + +fn discount_to_tag_parts( + discount: &RadrootsListingDiscount, +) -> Result<(&'static str, String), TradeListingParseError> { + #[cfg(feature = "serde_json")] + { + let (kind, payload) = match discount { + RadrootsListingDiscount::Quantity { + ref_quantity, + threshold, + value, + } => ("quantity", serde_json::to_string(&QuantityDiscountPayload { + ref_quantity: ref_quantity.clone(), + threshold: threshold.clone(), + value: value.clone(), + })), + RadrootsListingDiscount::Mass { threshold, value } => ("mass", serde_json::to_string(&MassDiscountPayload { + threshold: threshold.clone(), + value: value.clone(), + })), + RadrootsListingDiscount::Subtotal { threshold, value } => ("subtotal", serde_json::to_string(&SubtotalDiscountPayload { + threshold: threshold.clone(), + value: value.clone(), + })), + RadrootsListingDiscount::Total { total_min, value } => ("total", serde_json::to_string(&TotalDiscountPayload { + total_min: total_min.clone(), + value: value.clone(), + })), + }; + let payload = + payload.map_err(|_| TradeListingParseError::InvalidJson("discount".to_string()))?; + return Ok((kind, payload)); + } + #[cfg(not(feature = "serde_json"))] + { + let _ = discount; + Err(TradeListingParseError::InvalidJson("discount".to_string())) + } +} + +fn parse_discount( + kind: &str, + payload: &str, +) -> Result<RadrootsListingDiscount, TradeListingParseError> { + #[cfg(feature = "serde_json")] + { + match kind { + "quantity" => { + let data: QuantityDiscountPayload = + serde_json::from_str(payload).map_err(|_| TradeListingParseError::InvalidDiscount(kind.to_string()))?; + Ok(RadrootsListingDiscount::Quantity { + ref_quantity: data.ref_quantity, + threshold: data.threshold, + value: data.value, + }) + } + "mass" => { + let data: MassDiscountPayload = + serde_json::from_str(payload).map_err(|_| TradeListingParseError::InvalidDiscount(kind.to_string()))?; + Ok(RadrootsListingDiscount::Mass { + threshold: data.threshold, + value: data.value, + }) + } + "subtotal" => { + let data: SubtotalDiscountPayload = + serde_json::from_str(payload).map_err(|_| TradeListingParseError::InvalidDiscount(kind.to_string()))?; + Ok(RadrootsListingDiscount::Subtotal { + threshold: data.threshold, + value: data.value, + }) + } + "total" => { + let data: TotalDiscountPayload = + serde_json::from_str(payload).map_err(|_| TradeListingParseError::InvalidDiscount(kind.to_string()))?; + Ok(RadrootsListingDiscount::Total { + total_min: data.total_min, + value: data.value, + }) + } + _ => Err(TradeListingParseError::InvalidDiscount(kind.to_string())), + } + } + #[cfg(not(feature = "serde_json"))] + { + let _ = (kind, payload); + Err(TradeListingParseError::InvalidJson("discount".to_string())) + } +} + +#[cfg(feature = "serde_json")] +#[derive(serde::Deserialize, serde::Serialize, Clone)] +struct QuantityDiscountPayload { + ref_quantity: String, + threshold: RadrootsCoreQuantity, + value: RadrootsCoreMoney, +} + +#[cfg(feature = "serde_json")] +#[derive(serde::Deserialize, serde::Serialize, Clone)] +struct MassDiscountPayload { + threshold: RadrootsCoreQuantity, + value: RadrootsCoreMoney, +} + +#[cfg(feature = "serde_json")] +#[derive(serde::Deserialize, serde::Serialize, Clone)] +struct SubtotalDiscountPayload { + threshold: RadrootsCoreMoney, + value: radroots_core::RadrootsCoreDiscountValue, +} + +#[cfg(feature = "serde_json")] +#[derive(serde::Deserialize, serde::Serialize, Clone)] +struct TotalDiscountPayload { + total_min: RadrootsCoreMoney, + value: radroots_core::RadrootsCorePercent, +} diff --git a/trade/src/listing/dvm.rs b/trade/src/listing/dvm.rs @@ -0,0 +1,363 @@ +#![forbid(unsafe_code)] + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::RadrootsNostrEventPtr; +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +use crate::listing::dvm_kinds::{ + KIND_TRADE_LISTING_ANSWER_RES, KIND_TRADE_LISTING_CANCEL_REQ, + KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ, KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ, + KIND_TRADE_LISTING_DISCOUNT_OFFER_RES, KIND_TRADE_LISTING_DISCOUNT_REQ, + KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ, KIND_TRADE_LISTING_ORDER_REQ, + KIND_TRADE_LISTING_ORDER_RES, KIND_TRADE_LISTING_ORDER_REVISION_REQ, + KIND_TRADE_LISTING_ORDER_REVISION_RES, KIND_TRADE_LISTING_QUESTION_REQ, + KIND_TRADE_LISTING_RECEIPT_REQ, KIND_TRADE_LISTING_VALIDATE_REQ, + KIND_TRADE_LISTING_VALIDATE_RES, +}; +use crate::listing::order::{ + TradeAnswer, TradeDiscountDecision, TradeDiscountOffer, TradeDiscountRequest, TradeFulfillmentUpdate, + TradeOrder, TradeOrderRevision, TradeQuestion, TradeReceipt, +}; +use crate::listing::validation::TradeListingValidationError; + +pub const TRADE_LISTING_DOMAIN: &str = "trade:listing"; +pub const TRADE_LISTING_ENVELOPE_VERSION: u16 = 1; + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TradeListingDomain { + #[cfg_attr(feature = "serde", serde(rename = "trade:listing"))] + TradeListing, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TradeListingMessageType { + ListingValidateRequest, + ListingValidateResult, + OrderRequest, + OrderResponse, + OrderRevision, + OrderRevisionAccept, + OrderRevisionDecline, + Question, + Answer, + DiscountRequest, + DiscountOffer, + DiscountAccept, + DiscountDecline, + Cancel, + FulfillmentUpdate, + Receipt, +} + +impl TradeListingMessageType { + #[inline] + pub const fn kind(self) -> u16 { + match self { + TradeListingMessageType::ListingValidateRequest => KIND_TRADE_LISTING_VALIDATE_REQ, + TradeListingMessageType::ListingValidateResult => KIND_TRADE_LISTING_VALIDATE_RES, + TradeListingMessageType::OrderRequest => KIND_TRADE_LISTING_ORDER_REQ, + TradeListingMessageType::OrderResponse => KIND_TRADE_LISTING_ORDER_RES, + TradeListingMessageType::OrderRevision => KIND_TRADE_LISTING_ORDER_REVISION_REQ, + TradeListingMessageType::OrderRevisionAccept => KIND_TRADE_LISTING_ORDER_REVISION_RES, + TradeListingMessageType::OrderRevisionDecline => KIND_TRADE_LISTING_ORDER_REVISION_RES, + TradeListingMessageType::Question => KIND_TRADE_LISTING_QUESTION_REQ, + TradeListingMessageType::Answer => KIND_TRADE_LISTING_ANSWER_RES, + TradeListingMessageType::DiscountRequest => KIND_TRADE_LISTING_DISCOUNT_REQ, + TradeListingMessageType::DiscountOffer => KIND_TRADE_LISTING_DISCOUNT_OFFER_RES, + TradeListingMessageType::DiscountAccept => KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ, + TradeListingMessageType::DiscountDecline => KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ, + TradeListingMessageType::Cancel => KIND_TRADE_LISTING_CANCEL_REQ, + TradeListingMessageType::FulfillmentUpdate => KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ, + TradeListingMessageType::Receipt => KIND_TRADE_LISTING_RECEIPT_REQ, + } + } + + #[inline] + pub const fn requires_order_id(self) -> bool { + !matches!( + self, + TradeListingMessageType::ListingValidateRequest + | TradeListingMessageType::ListingValidateResult + ) + } + + #[inline] + pub const fn is_request(self) -> bool { + matches!( + self, + TradeListingMessageType::ListingValidateRequest + | TradeListingMessageType::OrderRequest + | TradeListingMessageType::OrderRevision + | TradeListingMessageType::Question + | TradeListingMessageType::DiscountRequest + | TradeListingMessageType::DiscountAccept + | TradeListingMessageType::DiscountDecline + | TradeListingMessageType::Cancel + | TradeListingMessageType::FulfillmentUpdate + | TradeListingMessageType::Receipt + ) + } + + #[inline] + pub const fn is_result(self) -> bool { + !self.is_request() + } +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeListingEnvelope<T> { + pub version: u16, + pub domain: TradeListingDomain, + #[cfg_attr(feature = "serde", serde(rename = "type"))] + pub message_type: TradeListingMessageType, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub order_id: Option<String>, + pub listing_addr: String, + pub payload: T, +} + +impl<T> TradeListingEnvelope<T> { + #[inline] + pub fn new( + message_type: TradeListingMessageType, + listing_addr: impl Into<String>, + order_id: Option<String>, + payload: T, + ) -> Self { + Self { + version: TRADE_LISTING_ENVELOPE_VERSION, + domain: TradeListingDomain::TradeListing, + message_type, + order_id, + listing_addr: listing_addr.into(), + payload, + } + } + + pub fn validate(&self) -> Result<(), TradeListingEnvelopeError> { + if self.version != TRADE_LISTING_ENVELOPE_VERSION { + return Err(TradeListingEnvelopeError::InvalidVersion { + expected: TRADE_LISTING_ENVELOPE_VERSION, + got: self.version, + }); + } + if self.listing_addr.trim().is_empty() { + return Err(TradeListingEnvelopeError::MissingListingAddr); + } + if self.message_type.requires_order_id() { + match self.order_id.as_deref() { + Some(id) if !id.trim().is_empty() => {} + _ => return Err(TradeListingEnvelopeError::MissingOrderId), + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TradeListingEnvelopeError { + InvalidVersion { expected: u16, got: u16 }, + MissingOrderId, + MissingListingAddr, +} + +impl core::fmt::Display for TradeListingEnvelopeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + TradeListingEnvelopeError::InvalidVersion { expected, got } => { + write!(f, "invalid envelope version: expected {expected}, got {got}") + } + TradeListingEnvelopeError::MissingOrderId => { + write!(f, "missing order_id for order-scoped message") + } + TradeListingEnvelopeError::MissingListingAddr => { + write!(f, "missing listing_addr") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for TradeListingEnvelopeError {} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeListingAddress { + pub kind: u16, + pub seller_pubkey: String, + pub listing_id: String, +} + +impl TradeListingAddress { + pub fn parse(addr: &str) -> Result<Self, TradeListingAddressError> { + let mut parts = addr.split(':'); + let kind = parts + .next() + .ok_or(TradeListingAddressError::InvalidFormat)? + .parse::<u16>() + .map_err(|_| TradeListingAddressError::InvalidFormat)?; + let seller_pubkey = parts + .next() + .ok_or(TradeListingAddressError::InvalidFormat)? + .to_string(); + let listing_id = parts + .next() + .ok_or(TradeListingAddressError::InvalidFormat)? + .to_string(); + if parts.next().is_some() { + return Err(TradeListingAddressError::InvalidFormat); + } + if kind == 0 || seller_pubkey.trim().is_empty() || listing_id.trim().is_empty() { + return Err(TradeListingAddressError::InvalidFormat); + } + Ok(Self { + kind, + seller_pubkey, + listing_id, + }) + } + + #[inline] + pub fn as_str(&self) -> String { + format!("{}:{}:{}", self.kind, self.seller_pubkey, self.listing_id) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TradeListingAddressError { + InvalidFormat, +} + +impl core::fmt::Display for TradeListingAddressError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + TradeListingAddressError::InvalidFormat => { + write!(f, "invalid listing address format") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for TradeListingAddressError {} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeListingValidateRequest { + #[cfg_attr(feature = "ts-rs", ts(optional, type = "RadrootsNostrEventPtr | null"))] + pub listing_event: Option<RadrootsNostrEventPtr>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeListingValidateResult { + pub valid: bool, + #[cfg_attr(feature = "ts-rs", ts(type = "TradeListingValidationError[]"))] + pub errors: Vec<TradeListingValidationError>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeOrderResponse { + pub accepted: bool, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub reason: Option<String>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeOrderRevisionResponse { + pub accepted: bool, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub reason: Option<String>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeListingCancel { + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub reason: Option<String>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TradeListingMessagePayload { + ListingValidateRequest(TradeListingValidateRequest), + ListingValidateResult(TradeListingValidateResult), + OrderRequest(TradeOrder), + OrderResponse(TradeOrderResponse), + OrderRevision(TradeOrderRevision), + OrderRevisionAccept(TradeOrderRevisionResponse), + OrderRevisionDecline(TradeOrderRevisionResponse), + Question(TradeQuestion), + Answer(TradeAnswer), + DiscountRequest(TradeDiscountRequest), + DiscountOffer(TradeDiscountOffer), + DiscountAccept(TradeDiscountDecision), + DiscountDecline(TradeDiscountDecision), + Cancel(TradeListingCancel), + FulfillmentUpdate(TradeFulfillmentUpdate), + Receipt(TradeReceipt), +} + +#[cfg(test)] +mod tests { + use super::{ + TradeListingEnvelope, TradeListingEnvelopeError, TradeListingMessageType, + TradeListingValidateRequest, + }; + + #[test] + fn envelope_requires_listing_addr() { + let env = TradeListingEnvelope::new( + TradeListingMessageType::ListingValidateRequest, + "", + None, + TradeListingValidateRequest { listing_event: None }, + ); + assert_eq!( + env.validate().unwrap_err(), + TradeListingEnvelopeError::MissingListingAddr + ); + } + + #[test] + fn envelope_requires_order_id_for_order_scoped() { + let env = TradeListingEnvelope::new( + TradeListingMessageType::OrderRequest, + "30402:pubkey:listing", + None, + TradeListingValidateRequest { listing_event: None }, + ); + assert_eq!( + env.validate().unwrap_err(), + TradeListingEnvelopeError::MissingOrderId + ); + } +} diff --git a/trade/src/listing/dvm_kinds.rs b/trade/src/listing/dvm_kinds.rs @@ -0,0 +1,108 @@ +#![forbid(unsafe_code)] + +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +pub const KIND_TRADE_LISTING_VALIDATE_REQ: u16 = 5321; +pub const KIND_TRADE_LISTING_VALIDATE_RES: u16 = 6321; + +pub const KIND_TRADE_LISTING_ORDER_REQ: u16 = 5322; +pub const KIND_TRADE_LISTING_ORDER_RES: u16 = 6322; + +pub const KIND_TRADE_LISTING_ORDER_REVISION_REQ: u16 = 5323; +pub const KIND_TRADE_LISTING_ORDER_REVISION_RES: u16 = 6323; + +pub const KIND_TRADE_LISTING_QUESTION_REQ: u16 = 5324; +pub const KIND_TRADE_LISTING_ANSWER_RES: u16 = 6324; + +pub const KIND_TRADE_LISTING_DISCOUNT_REQ: u16 = 5325; +pub const KIND_TRADE_LISTING_DISCOUNT_OFFER_RES: u16 = 6325; + +pub const KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ: u16 = 5326; +pub const KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ: u16 = 5327; + +pub const KIND_TRADE_LISTING_CANCEL_REQ: u16 = 5328; +pub const KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ: u16 = 5329; +pub const KIND_TRADE_LISTING_RECEIPT_REQ: u16 = 5330; + +pub const TRADE_LISTING_DVM_KINDS: [u16; 15] = [ + KIND_TRADE_LISTING_VALIDATE_REQ, + KIND_TRADE_LISTING_VALIDATE_RES, + KIND_TRADE_LISTING_ORDER_REQ, + KIND_TRADE_LISTING_ORDER_RES, + KIND_TRADE_LISTING_ORDER_REVISION_REQ, + KIND_TRADE_LISTING_ORDER_REVISION_RES, + KIND_TRADE_LISTING_QUESTION_REQ, + KIND_TRADE_LISTING_ANSWER_RES, + KIND_TRADE_LISTING_DISCOUNT_REQ, + KIND_TRADE_LISTING_DISCOUNT_OFFER_RES, + KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ, + KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ, + KIND_TRADE_LISTING_CANCEL_REQ, + KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ, + KIND_TRADE_LISTING_RECEIPT_REQ, +]; + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr( + feature = "ts-rs", + ts( + export, + export_to = "types.ts", + rename_all = "SCREAMING_SNAKE_CASE", + repr(enum) + ) +)] +#[repr(u16)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum TradeListingDvmKind { + KindTradeListingValidateReq = KIND_TRADE_LISTING_VALIDATE_REQ, + KindTradeListingValidateRes = KIND_TRADE_LISTING_VALIDATE_RES, + KindTradeListingOrderReq = KIND_TRADE_LISTING_ORDER_REQ, + KindTradeListingOrderRes = KIND_TRADE_LISTING_ORDER_RES, + KindTradeListingOrderRevisionReq = KIND_TRADE_LISTING_ORDER_REVISION_REQ, + KindTradeListingOrderRevisionRes = KIND_TRADE_LISTING_ORDER_REVISION_RES, + KindTradeListingQuestionReq = KIND_TRADE_LISTING_QUESTION_REQ, + KindTradeListingAnswerRes = KIND_TRADE_LISTING_ANSWER_RES, + KindTradeListingDiscountReq = KIND_TRADE_LISTING_DISCOUNT_REQ, + KindTradeListingDiscountOfferRes = KIND_TRADE_LISTING_DISCOUNT_OFFER_RES, + KindTradeListingDiscountAcceptReq = KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ, + KindTradeListingDiscountDeclineReq = KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ, + KindTradeListingCancelReq = KIND_TRADE_LISTING_CANCEL_REQ, + KindTradeListingFulfillmentUpdateReq = KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ, + KindTradeListingReceiptReq = KIND_TRADE_LISTING_RECEIPT_REQ, +} + +#[inline] +pub const fn is_trade_listing_dvm_request_kind(kind: u16) -> bool { + matches!( + kind, + KIND_TRADE_LISTING_VALIDATE_REQ + | KIND_TRADE_LISTING_ORDER_REQ + | KIND_TRADE_LISTING_ORDER_REVISION_REQ + | KIND_TRADE_LISTING_QUESTION_REQ + | KIND_TRADE_LISTING_DISCOUNT_REQ + | KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ + | KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ + | KIND_TRADE_LISTING_CANCEL_REQ + | KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ + | KIND_TRADE_LISTING_RECEIPT_REQ + ) +} + +#[inline] +pub const fn is_trade_listing_dvm_result_kind(kind: u16) -> bool { + matches!( + kind, + KIND_TRADE_LISTING_VALIDATE_RES + | KIND_TRADE_LISTING_ORDER_RES + | KIND_TRADE_LISTING_ORDER_REVISION_RES + | KIND_TRADE_LISTING_ANSWER_RES + | KIND_TRADE_LISTING_DISCOUNT_OFFER_RES + ) +} + +#[inline] +pub const fn is_trade_listing_dvm_kind(kind: u16) -> bool { + is_trade_listing_dvm_request_kind(kind) || is_trade_listing_dvm_result_kind(kind) +} diff --git a/trade/src/listing/mod.rs b/trade/src/listing/mod.rs @@ -1,8 +1,13 @@ +pub mod codec; +pub mod dvm; +pub mod dvm_kinds; pub mod kinds; pub mod meta; pub mod model; pub mod price_ext; pub mod tags; +pub mod validation; +pub mod order; pub mod stage { pub mod accept; diff --git a/trade/src/listing/order.rs b/trade/src/listing/order.rs @@ -0,0 +1,199 @@ +#![forbid(unsafe_code)] + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_core::{RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCoreQuantity}; +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeOrderItem { + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreQuantity"))] + pub quantity: RadrootsCoreQuantity, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreMoney"))] + pub unit_price: RadrootsCoreMoney, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TradeOrderChange { + Quantity { + item_index: u32, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreQuantity"))] + quantity: RadrootsCoreQuantity, + }, + Price { + item_index: u32, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreMoney"))] + unit_price: RadrootsCoreMoney, + }, + ItemAdd { + item: TradeOrderItem, + }, + ItemRemove { + item_index: u32, + }, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeOrderRevision { + pub revision_id: String, + pub order_id: String, + pub changes: Vec<TradeOrderChange>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub reason: Option<String>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeOrder { + pub order_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub items: Vec<TradeOrderItem>, + #[cfg_attr( + feature = "ts-rs", + ts(optional, type = "RadrootsCoreDiscountValue[] | null") + )] + pub discounts: Option<Vec<RadrootsCoreDiscountValue>>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub notes: Option<String>, + pub status: TradeOrderStatus, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TradeOrderStatus { + Draft, + Validated, + Requested, + Questioned, + Revised, + Accepted, + Declined, + Cancelled, + Fulfilled, + Completed, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeQuestion { + pub question_id: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub order_id: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub listing_addr: Option<String>, + pub question_text: String, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeAnswer { + pub question_id: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub order_id: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub listing_addr: Option<String>, + pub answer_text: String, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeDiscountRequest { + pub discount_id: String, + pub order_id: String, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDiscountValue"))] + pub value: RadrootsCoreDiscountValue, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub conditions: Option<String>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeDiscountOffer { + pub discount_id: String, + pub order_id: String, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDiscountValue"))] + pub value: RadrootsCoreDiscountValue, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub conditions: Option<String>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TradeDiscountDecision { + Accept { + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDiscountValue"))] + value: RadrootsCoreDiscountValue, + }, + Decline { + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + reason: Option<String>, + }, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TradeFulfillmentStatus { + Preparing, + Shipped, + ReadyForPickup, + Delivered, + Cancelled, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeFulfillmentUpdate { + pub status: TradeFulfillmentStatus, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub tracking: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub eta: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub notes: Option<String>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeReceipt { + pub acknowledged: bool, + pub at: u64, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub note: Option<String>, +} diff --git a/trade/src/listing/validation.rs b/trade/src/listing/validation.rs @@ -0,0 +1,339 @@ +#![forbid(unsafe_code)] + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_core::{RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit}; +use radroots_events::{ + RadrootsNostrEvent, + listing::{ + RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, + RadrootsListingLocation, + }, +}; +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +use crate::listing::codec::{listing_from_event_parts, TradeListingParseError}; +use crate::listing::dvm::TradeListingAddress; + +const LISTING_KIND: u32 = 30402; + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsTradeListing { + pub listing_id: String, + pub listing_addr: String, + pub seller_pubkey: String, + pub title: String, + pub description: String, + pub product_type: String, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreUnit"))] + pub unit: RadrootsCoreUnit, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreMoney"))] + pub unit_price: RadrootsCoreMoney, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDecimal"))] + pub inventory_available: RadrootsCoreDecimal, + pub availability: RadrootsListingAvailability, + pub location: RadrootsListingLocation, + pub delivery_method: RadrootsListingDeliveryMethod, + pub listing: RadrootsListing, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TradeListingValidationError { + InvalidKind { kind: u32 }, + MissingListingId, + ListingEventNotFound { listing_addr: String }, + ListingEventFetchFailed { listing_addr: String }, + ParseError { error: TradeListingParseError }, + MissingTitle, + MissingDescription, + MissingProductType, + MissingPrice, + InvalidPrice, + MissingInventory, + InvalidInventory, + MissingAvailability, + MissingLocation, + MissingDeliveryMethod, +} + +impl core::fmt::Display for TradeListingValidationError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + TradeListingValidationError::InvalidKind { kind } => { + write!(f, "invalid listing kind: {kind}") + } + TradeListingValidationError::MissingListingId => write!(f, "missing listing id"), + TradeListingValidationError::ListingEventNotFound { listing_addr } => { + write!(f, "listing event not found: {listing_addr}") + } + TradeListingValidationError::ListingEventFetchFailed { listing_addr } => { + write!(f, "listing event fetch failed: {listing_addr}") + } + TradeListingValidationError::ParseError { error } => { + write!(f, "invalid listing data: {error}") + } + TradeListingValidationError::MissingTitle => write!(f, "missing listing title"), + TradeListingValidationError::MissingDescription => { + write!(f, "missing listing description") + } + TradeListingValidationError::MissingProductType => { + write!(f, "missing listing product type") + } + TradeListingValidationError::MissingPrice => write!(f, "missing listing price"), + TradeListingValidationError::InvalidPrice => write!(f, "invalid listing price"), + TradeListingValidationError::MissingInventory => { + write!(f, "missing listing inventory") + } + TradeListingValidationError::InvalidInventory => { + write!(f, "invalid listing inventory") + } + TradeListingValidationError::MissingAvailability => { + write!(f, "missing listing availability") + } + TradeListingValidationError::MissingLocation => write!(f, "missing listing location"), + TradeListingValidationError::MissingDeliveryMethod => { + write!(f, "missing listing delivery method") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for TradeListingValidationError {} + +pub fn validate_listing_event( + event: &RadrootsNostrEvent, +) -> Result<RadrootsTradeListing, TradeListingValidationError> { + if event.kind != LISTING_KIND { + return Err(TradeListingValidationError::InvalidKind { kind: event.kind }); + } + + let listing = listing_from_event_parts(&event.tags, &event.content) + .map_err(|error| TradeListingValidationError::ParseError { error })?; + let listing_id = listing.d_tag.trim().to_string(); + if listing_id.is_empty() { + return Err(TradeListingValidationError::MissingListingId); + } + + let seller_pubkey = event.author.clone(); + let listing_addr = TradeListingAddress { + kind: LISTING_KIND as u16, + seller_pubkey: seller_pubkey.clone(), + listing_id: listing_id.clone(), + } + .as_str(); + + let title = listing.product.title.trim().to_string(); + if title.is_empty() { + return Err(TradeListingValidationError::MissingTitle); + } + + let description = listing + .product + .summary + .as_ref() + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + if description.is_empty() { + return Err(TradeListingValidationError::MissingDescription); + } + + let product_type = if !listing.product.category.trim().is_empty() { + listing.product.category.trim().to_string() + } else { + listing.product.key.trim().to_string() + }; + if product_type.is_empty() { + return Err(TradeListingValidationError::MissingProductType); + } + + let price = listing + .prices + .first() + .ok_or(TradeListingValidationError::MissingPrice)?; + if price.amount.amount.is_sign_negative() { + return Err(TradeListingValidationError::InvalidPrice); + } + + let inventory_available = + listing + .inventory_available + .clone() + .or_else(|| derive_inventory(&listing)) + .ok_or(TradeListingValidationError::MissingInventory)?; + if inventory_available.is_sign_negative() { + return Err(TradeListingValidationError::InvalidInventory); + } + + let availability = listing + .availability + .clone() + .ok_or(TradeListingValidationError::MissingAvailability)?; + let location = listing + .location + .clone() + .ok_or(TradeListingValidationError::MissingLocation)?; + let delivery_method = listing + .delivery_method + .clone() + .ok_or(TradeListingValidationError::MissingDeliveryMethod)?; + + Ok(RadrootsTradeListing { + listing_id, + listing_addr, + seller_pubkey, + title, + description, + product_type, + unit: price.quantity.unit, + unit_price: price.amount.clone(), + inventory_available, + availability, + location, + delivery_method, + listing, + }) +} + +fn derive_inventory(listing: &RadrootsListing) -> Option<RadrootsCoreDecimal> { + listing + .quantities + .iter() + .find_map(|qty| qty.count.map(|count| qty.value.amount * RadrootsCoreDecimal::from(count))) +} + +#[cfg(test)] +mod tests { + use super::{validate_listing_event, TradeListingValidationError}; + use radroots_core::{ + RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, + RadrootsCoreUnit, + }; + use radroots_events::{ + RadrootsNostrEvent, + listing::{ + RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, + RadrootsListingLocation, RadrootsListingProduct, RadrootsListingQuantity, + }, + }; + + fn base_listing() -> RadrootsListing { + RadrootsListing { + d_tag: "listing-1".into(), + product: RadrootsListingProduct { + key: "coffee".into(), + title: "Coffee".into(), + category: "coffee".into(), + summary: Some("Single origin coffee".into()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + quantities: vec![RadrootsListingQuantity { + value: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassLb, + ), + label: None, + count: Some(5), + }], + prices: vec![RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(20u32), + RadrootsCoreCurrency::USD, + ), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassLb, + ), + }], + discounts: None, + inventory_available: None, + availability: Some(RadrootsListingAvailability::Status { + status: radroots_events::listing::RadrootsListingStatus::Active, + }), + delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), + location: Some(RadrootsListingLocation { + primary: "Farm".into(), + city: None, + region: None, + country: None, + lat: None, + lng: None, + geohash: None, + }), + images: None, + } + } + + fn base_event(listing: &RadrootsListing) -> RadrootsNostrEvent { + RadrootsNostrEvent { + id: "evt".into(), + author: "seller".into(), + created_at: 0, + kind: 30402, + tags: vec![vec!["d".into(), listing.d_tag.clone()]], + content: serde_json::to_string(listing).unwrap(), + sig: "sig".into(), + } + } + + #[test] + fn validate_listing_ok() { + let listing = base_listing(); + let event = base_event(&listing); + assert!(validate_listing_event(&event).is_ok()); + } + + #[test] + fn validate_listing_rejects_missing_d_tag() { + let listing = base_listing(); + let mut event = base_event(&listing); + event.tags.clear(); + let err = validate_listing_event(&event).unwrap_err(); + assert!(matches!(err, TradeListingValidationError::ParseError { .. })); + } + + #[test] + fn validate_listing_rejects_invalid_currency() { + let mut event = base_event(&base_listing()); + event.content = String::new(); + event.tags = vec![ + vec!["d".into(), "listing-1".into()], + vec!["key".into(), "coffee".into()], + vec!["title".into(), "Coffee".into()], + vec!["category".into(), "coffee".into()], + vec!["summary".into(), "Single origin".into()], + vec!["quantity".into(), "1".into(), "lb".into(), "bag".into(), "5".into()], + vec!["price".into(), "20".into(), "US".into(), "1".into(), "lb".into()], + vec!["location".into(), "Farm".into(), "Town".into(), "Region".into()], + vec!["status".into(), "active".into()], + vec!["delivery".into(), "pickup".into()], + ]; + let err = validate_listing_event(&event).unwrap_err(); + assert!(matches!(err, TradeListingValidationError::ParseError { .. })); + } + + #[test] + fn validate_listing_rejects_missing_inventory() { + let mut listing = base_listing(); + listing.quantities[0].count = None; + let event = base_event(&listing); + let err = validate_listing_event(&event).unwrap_err(); + assert!(matches!( + err, + TradeListingValidationError::MissingInventory + )); + } +}