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:
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
+ ));
+ }
+}