commit da89a3dac1107db9b81fd749db912379e989d37f
parent 4f212dd2939764133bfdd91ec834292738b2722a
Author: triesap <tyson@radroots.org>
Date: Thu, 2 Apr 2026 16:13:27 +0000
listing: align listing lane with nip-99
- add 30403 draft listing kind support across events and trade validation
- encode listing content as markdown and reconstruct listings from canonical tags
- replace availability window published_at tags with radroots availability tags
- cover the listing lane changes in codec and trade test suites
Diffstat:
10 files changed, 673 insertions(+), 138 deletions(-)
diff --git a/crates/events-codec/Cargo.toml b/crates/events-codec/Cargo.toml
@@ -15,7 +15,7 @@ readme.workspace = true
[features]
default = ["std"]
-std = []
+std = ["radroots-core/std", "radroots-events/std"]
serde = ["dep:serde", "radroots-core/serde", "radroots-events/serde"]
serde_json = ["serde", "dep:serde_json"]
nostr = ["dep:nostr", "std"]
diff --git a/crates/events-codec/src/listing/decode.rs b/crates/events-codec/src/listing/decode.rs
@@ -6,11 +6,19 @@ use alloc::{
vec::Vec,
};
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney,
+ RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
use radroots_events::{
RadrootsNostrEvent,
- kinds::KIND_LISTING,
- kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA},
- listing::{RadrootsListing, RadrootsListingFarmRef},
+ kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, is_listing_kind},
+ listing::{
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
+ RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingImage,
+ RadrootsListingImageSize, RadrootsListingLocation, RadrootsListingProduct,
+ RadrootsListingStatus,
+ },
plot::RadrootsPlotRef,
resource_area::RadrootsResourceAreaRef,
tags::TAG_D,
@@ -20,20 +28,63 @@ use crate::d_tag::validate_d_tag_tag;
use crate::error::EventParseError;
use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
-const DEFAULT_KIND: u32 = KIND_LISTING;
+const EXPECTED_LISTING_KINDS: &str = "30402 or 30403";
const TAG_A: &str = "a";
const TAG_P: &str = "p";
+const TAG_PRICE: &str = "price";
+const TAG_RADROOTS_BIN: &str = "radroots:bin";
+const TAG_RADROOTS_PRICE: &str = "radroots:price";
+const TAG_RADROOTS_DISCOUNT: &str = "radroots:discount";
+const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin";
const TAG_RADROOTS_RESOURCE_AREA: &str = "radroots:resource_area";
const TAG_RADROOTS_PLOT: &str = "radroots:plot";
+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_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start";
+const TAG_STATUS: &str = "status";
+const TAG_EXPIRES_AT: &str = "expires_at";
+
+fn parse_decimal(value: &str, field: &'static str) -> Result<RadrootsCoreDecimal, EventParseError> {
+ value
+ .parse::<RadrootsCoreDecimal>()
+ .map_err(|_| EventParseError::InvalidTag(field))
+}
+
+fn parse_currency(
+ value: &str,
+ field: &'static str,
+) -> Result<RadrootsCoreCurrency, EventParseError> {
+ let upper = value.trim().to_ascii_uppercase();
+ RadrootsCoreCurrency::from_str_upper(&upper).map_err(|_| EventParseError::InvalidTag(field))
+}
+
+fn parse_unit(value: &str, field: &'static str) -> Result<RadrootsCoreUnit, EventParseError> {
+ value
+ .parse::<RadrootsCoreUnit>()
+ .map_err(|_| EventParseError::InvalidTag(field))
+}
+
+fn parse_u64_tag_value(
+ value: Option<&String>,
+ field: &'static str,
+) -> Result<u64, EventParseError> {
+ value
+ .ok_or(EventParseError::InvalidTag(field))?
+ .parse::<u64>()
+ .map_err(|_| EventParseError::InvalidTag(field))
+}
fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> {
let tag = tags
.iter()
- .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_D))
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D))
.ok_or(EventParseError::MissingTag(TAG_D))?;
let value = tag
.get(1)
- .map(|s| s.to_string())
+ .map(|value| value.to_string())
.ok_or(EventParseError::InvalidTag(TAG_D))?;
if value.trim().is_empty() {
return Err(EventParseError::InvalidTag(TAG_D));
@@ -45,16 +96,16 @@ fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> {
fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsListingFarmRef, EventParseError> {
for tag in tags
.iter()
- .filter(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_A))
+ .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
{
let value = tag
.get(1)
- .map(|s| s.to_string())
+ .map(|value| value.to_string())
.ok_or(EventParseError::InvalidTag(TAG_A))?;
let mut parts = value.splitn(3, ':');
let kind = parts
.next()
- .and_then(|v| v.parse::<u32>().ok())
+ .and_then(|raw| raw.parse::<u32>().ok())
.ok_or(EventParseError::InvalidTag(TAG_A))?;
if kind != KIND_FARM {
continue;
@@ -79,11 +130,11 @@ fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsListingFarmRef, EventP
fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, EventParseError> {
let tag = tags
.iter()
- .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_P))
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_P))
.ok_or(EventParseError::MissingTag(TAG_P))?;
let value = tag
.get(1)
- .map(|s| s.to_string())
+ .map(|value| value.to_string())
.ok_or(EventParseError::InvalidTag(TAG_P))?;
if value.trim().is_empty() {
return Err(EventParseError::InvalidTag(TAG_P));
@@ -96,18 +147,18 @@ fn parse_resource_area(
) -> Result<Option<RadrootsResourceAreaRef>, EventParseError> {
let tag = tags
.iter()
- .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA));
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA));
let Some(tag) = tag else {
return Ok(None);
};
let value = tag
.get(1)
- .map(|s| s.to_string())
+ .map(|value| value.to_string())
.ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?;
let mut parts = value.splitn(3, ':');
let kind = parts
.next()
- .and_then(|v| v.parse::<u32>().ok())
+ .and_then(|raw| raw.parse::<u32>().ok())
.ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?;
if kind != KIND_RESOURCE_AREA {
return Err(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA));
@@ -130,18 +181,18 @@ fn parse_resource_area(
fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, EventParseError> {
let tag = tags
.iter()
- .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_RADROOTS_PLOT));
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_PLOT));
let Some(tag) = tag else {
return Ok(None);
};
let value = tag
.get(1)
- .map(|s| s.to_string())
+ .map(|value| value.to_string())
.ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?;
let mut parts = value.splitn(3, ':');
let kind = parts
.next()
- .and_then(|v| v.parse::<u32>().ok())
+ .and_then(|raw| raw.parse::<u32>().ok())
.ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?;
if kind != KIND_PLOT {
return Err(EventParseError::InvalidTag(TAG_RADROOTS_PLOT));
@@ -166,61 +217,300 @@ pub fn listing_from_event(
tags: &[Vec<String>],
content: &str,
) -> Result<RadrootsListing, EventParseError> {
- if kind != DEFAULT_KIND {
+ if !is_listing_kind(kind) {
return Err(EventParseError::InvalidKind {
- expected: "30402",
+ expected: EXPECTED_LISTING_KINDS,
got: kind,
});
}
- if content.trim().is_empty() {
- return Err(EventParseError::InvalidJson("content"));
- }
+ listing_from_event_parts(tags, content)
+}
+
+pub fn listing_from_event_parts(
+ tags: &[Vec<String>],
+ _content: &str,
+) -> Result<RadrootsListing, EventParseError> {
let d_tag = parse_d_tag(tags)?;
let farm_ref = parse_farm_ref(tags)?;
let farm_pubkey = parse_farm_pubkey(tags)?;
let resource_area = parse_resource_area(tags)?;
let plot = parse_plot_ref(tags)?;
- let mut listing: RadrootsListing =
- serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?;
- if listing.d_tag.trim().is_empty() {
- listing.d_tag = d_tag;
- } else if listing.d_tag != d_tag {
- return Err(EventParseError::InvalidTag(TAG_D));
- }
+ 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 primary_bin_id: Option<String> = None;
+ let mut bin_drafts: Vec<BinDraft> = Vec::new();
+ let mut bin_order = 0usize;
+ let mut discounts: Vec<RadrootsCoreDiscount> = 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;
- if listing.farm.pubkey.trim().is_empty() || listing.farm.d_tag.trim().is_empty() {
- listing.farm = farm_ref;
- } else if listing.farm.pubkey != farm_ref.pubkey || listing.farm.d_tag != farm_ref.d_tag {
- return Err(EventParseError::InvalidTag(TAG_A));
- }
- if listing.farm.pubkey != farm_pubkey {
- return Err(EventParseError::InvalidTag(TAG_P));
- }
+ let has_structured_location = tags
+ .iter()
+ .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_LOCATION) && tag.len() >= 3);
- if let Some(tag_area) = resource_area {
- match listing.resource_area.as_ref() {
- None => listing.resource_area = Some(tag_area),
- Some(area) => {
- if area.pubkey != tag_area.pubkey || area.d_tag != tag_area.d_tag {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA));
+ for tag in tags {
+ if tag.is_empty() {
+ continue;
+ }
+ match tag[0].as_str() {
+ "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" => {
+ let parse_structured_location = match tag.len() {
+ 0 | 1 => false,
+ 2 => !has_structured_location && location.is_none(),
+ _ => true,
+ };
+ if parse_structured_location {
+ let primary = tag
+ .get(1)
+ .and_then(|value| clean_value(value))
+ .ok_or(EventParseError::InvalidTag(TAG_LOCATION))?;
+ let mut parsed = RadrootsListingLocation {
+ primary,
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: None,
+ };
+ if let Some(city) = tag.get(2).and_then(|value| clean_value(value)) {
+ parsed.city = Some(city);
+ }
+ if let Some(region) = tag.get(3).and_then(|value| clean_value(value)) {
+ parsed.region = Some(region);
+ }
+ if let Some(country) = tag.get(4).and_then(|value| clean_value(value)) {
+ parsed.country = Some(country);
+ }
+ location = Some(parsed);
+ } 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_PRICE => {}
+ TAG_RADROOTS_PRIMARY_BIN => {
+ let value = tag
+ .get(1)
+ .and_then(|value| clean_value(value))
+ .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?;
+ if let Some(existing) = primary_bin_id.as_ref() {
+ if existing != &value {
+ return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN));
+ }
+ } else {
+ primary_bin_id = Some(value);
+ }
+ }
+ TAG_RADROOTS_BIN => {
+ if tag.len() < 4 || tag.len() > 7 {
+ return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN));
+ }
+ let bin_id = tag
+ .get(1)
+ .and_then(|value| clean_value(value))
+ .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_BIN))?;
+ let amount = parse_decimal(&tag[2], TAG_RADROOTS_BIN)?;
+ let unit = parse_unit(&tag[3], TAG_RADROOTS_BIN)?;
+ if unit != unit.canonical_unit() {
+ return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN));
+ }
+ let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
+ if bin.quantity.is_some() {
+ return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN));
+ }
+ bin.quantity = Some(RadrootsCoreQuantity::new(amount, unit));
+
+ match tag.as_slice() {
+ [_, _, _, _, display_amount_raw, display_unit_raw]
+ | [_, _, _, _, display_amount_raw, display_unit_raw, _] => {
+ let display_amount = parse_decimal(display_amount_raw, TAG_RADROOTS_BIN)?;
+ let display_unit = parse_unit(display_unit_raw, TAG_RADROOTS_BIN)?;
+ bin.display_amount = Some(display_amount);
+ bin.display_unit = Some(display_unit);
+ if let [_, _, _, _, _, _, label] = tag.as_slice() {
+ bin.display_label = clean_value(label);
+ }
+ }
+ [_, _, _, _, _] => return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN)),
+ _ => {}
+ }
+ }
+ TAG_RADROOTS_PRICE => {
+ if tag.len() < 6 || tag.len() > 8 {
+ return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
+ }
+ let bin_id = tag
+ .get(1)
+ .and_then(|value| clean_value(value))
+ .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PRICE))?;
+ let amount = parse_decimal(&tag[2], TAG_RADROOTS_PRICE)?;
+ let currency = parse_currency(&tag[3], TAG_RADROOTS_PRICE)?;
+ let per_amount = parse_decimal(&tag[4], TAG_RADROOTS_PRICE)?;
+ let per_unit = parse_unit(&tag[5], TAG_RADROOTS_PRICE)?;
+ let price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(amount, currency),
+ RadrootsCoreQuantity::new(per_amount, per_unit),
+ );
+ if !price_per_canonical_unit.is_price_per_canonical_unit() {
+ return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
+ }
+ let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
+ if bin.price_per_canonical_unit.is_some() {
+ return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
+ }
+ bin.price_per_canonical_unit = Some(price_per_canonical_unit);
- if let Some(tag_plot) = plot {
- match listing.plot.as_ref() {
- None => listing.plot = Some(tag_plot),
- Some(existing) => {
- if existing.pubkey != tag_plot.pubkey || existing.d_tag != tag_plot.d_tag {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_PLOT));
+ match tag.as_slice() {
+ [_, _, _, _, _, _, _] => {
+ return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
+ }
+ [_, _, _, _, _, _, display_price_raw, display_unit_raw] => {
+ let display_price = parse_decimal(display_price_raw, TAG_RADROOTS_PRICE)?;
+ let display_unit = parse_unit(display_unit_raw, TAG_RADROOTS_PRICE)?;
+ bin.display_price = Some(RadrootsCoreMoney::new(display_price, currency));
+ bin.display_price_unit = Some(display_unit);
+ }
+ _ => {}
+ }
+ }
+ TAG_RADROOTS_DISCOUNT => {
+ let payload = tag
+ .get(1)
+ .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_DISCOUNT))?;
+ discounts.push(parse_discount(payload)?);
+ }
+ TAG_GEOHASH => {
+ if let Some(value) = tag.get(1).and_then(|value| clean_value(value)) {
+ geohash = Some(value);
+ }
+ }
+ TAG_INVENTORY => {
+ let value = tag
+ .get(1)
+ .ok_or(EventParseError::InvalidTag(TAG_INVENTORY))?;
+ inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?);
+ }
+ TAG_RADROOTS_AVAILABILITY_START => {
+ availability_start = Some(parse_u64_tag_value(
+ tag.get(1),
+ TAG_RADROOTS_AVAILABILITY_START,
+ )?);
+ }
+ TAG_EXPIRES_AT => {
+ availability_end = Some(parse_u64_tag_value(tag.get(1), TAG_EXPIRES_AT)?);
+ }
+ TAG_STATUS => {
+ let status = tag
+ .get(1)
+ .and_then(|value| clean_value(value))
+ .unwrap_or_default();
+ availability_status = Some(parse_status(&status));
+ }
+ TAG_DELIVERY => {
+ let method = tag
+ .get(1)
+ .and_then(|value| clean_value(value))
+ .unwrap_or_default();
+ delivery_method = Some(match method.as_str() {
+ "pickup" => RadrootsListingDeliveryMethod::Pickup,
+ "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery,
+ "shipping" => RadrootsListingDeliveryMethod::Shipping,
+ "other" => RadrootsListingDeliveryMethod::Other {
+ method: tag
+ .get(2)
+ .and_then(|value| clean_value(value))
+ .unwrap_or_default(),
+ },
+ other => RadrootsListingDeliveryMethod::Other {
+ method: other.to_string(),
+ },
+ });
+ }
+ TAG_IMAGE => {
+ let url = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_IMAGE))?;
+ if url.trim().is_empty() {
+ continue;
}
+ images.push(RadrootsListingImage {
+ url: url.to_string(),
+ size: tag.get(2).and_then(|value| parse_image_size(value)),
+ });
}
+ _ => {}
}
}
- Ok(listing)
+ 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 location| {
+ location.geohash = location.geohash.or(geohash);
+ location
+ });
+
+ if farm_pubkey != farm_ref.pubkey {
+ return Err(EventParseError::InvalidTag(TAG_P));
+ }
+
+ let primary_bin_id =
+ primary_bin_id.ok_or(EventParseError::MissingTag(TAG_RADROOTS_PRIMARY_BIN))?;
+ let bins = build_bins(bin_drafts)?;
+ if !bins.iter().any(|bin| bin.bin_id == primary_bin_id) {
+ return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN));
+ }
+
+ Ok(RadrootsListing {
+ d_tag,
+ farm: farm_ref,
+ product,
+ primary_bin_id,
+ bins,
+ resource_area,
+ plot,
+ discounts: if discounts.is_empty() {
+ None
+ } else {
+ Some(discounts)
+ },
+ inventory_available,
+ availability,
+ delivery_method,
+ location,
+ images: if images.is_empty() {
+ None
+ } else {
+ Some(images)
+ },
+ })
}
pub fn data_from_event(
@@ -258,16 +548,151 @@ pub fn parsed_from_event(
content.clone(),
tags.clone(),
)?;
- Ok(RadrootsParsedEvent {
- event: RadrootsNostrEvent {
- id,
- author,
- created_at: published_at,
- kind,
- content,
- tags,
- sig,
+ Ok(RadrootsParsedEvent::from_parts(
+ id,
+ author,
+ published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ data.data,
+ ))
+}
+
+pub fn data_from_nostr_event(
+ event: &RadrootsNostrEvent,
+) -> Result<RadrootsParsedData<RadrootsListing>, EventParseError> {
+ data_from_event(
+ event.id.clone(),
+ event.author.clone(),
+ event.created_at,
+ event.kind,
+ event.content.clone(),
+ event.tags.clone(),
+ )
+}
+
+pub fn parsed_from_nostr_event(
+ event: &RadrootsNostrEvent,
+) -> Result<RadrootsParsedEvent<RadrootsListing>, EventParseError> {
+ parsed_from_event(
+ event.id.clone(),
+ event.author.clone(),
+ event.created_at,
+ event.kind,
+ event.content.clone(),
+ event.tags.clone(),
+ event.sig.clone(),
+ )
+}
+
+fn clean_value(value: &str) -> Option<String> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
+ None
+ } else {
+ Some(trimmed.to_string())
+ }
+}
+
+fn set_if_empty(target: &mut String, value: Option<&String>) {
+ if target.trim().is_empty() {
+ if let Some(value) = value.and_then(|value| clean_value(value)) {
+ *target = value;
+ }
+ }
+}
+
+fn set_optional(target: &mut Option<String>, value: Option<&String>) {
+ if target.is_none() {
+ if let Some(value) = value.and_then(|value| clean_value(value)) {
+ *target = Some(value);
+ }
+ }
+}
+
+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(),
},
- data,
- })
+ }
+}
+
+fn parse_image_size(value: &str) -> Option<RadrootsListingImageSize> {
+ let (w_raw, h_raw) = value.split_once('x')?;
+ let w = w_raw.parse::<u32>().ok()?;
+ let h = h_raw.parse::<u32>().ok()?;
+ Some(RadrootsListingImageSize { w, h })
+}
+
+fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, EventParseError> {
+ serde_json::from_str(payload).map_err(|_| EventParseError::InvalidTag(TAG_RADROOTS_DISCOUNT))
+}
+
+#[derive(Clone, Debug)]
+struct BinDraft {
+ bin_id: String,
+ order_index: usize,
+ quantity: Option<RadrootsCoreQuantity>,
+ display_amount: Option<RadrootsCoreDecimal>,
+ display_unit: Option<RadrootsCoreUnit>,
+ display_label: Option<String>,
+ price_per_canonical_unit: Option<RadrootsCoreQuantityPrice>,
+ display_price: Option<RadrootsCoreMoney>,
+ display_price_unit: Option<RadrootsCoreUnit>,
+}
+
+fn upsert_bin<'a>(
+ bins: &'a mut Vec<BinDraft>,
+ bin_id: &str,
+ order_index: &mut usize,
+) -> &'a mut BinDraft {
+ if let Some(position) = bins.iter().position(|bin| bin.bin_id == bin_id) {
+ return &mut bins[position];
+ }
+ bins.push(BinDraft {
+ bin_id: bin_id.to_string(),
+ order_index: *order_index,
+ quantity: None,
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ price_per_canonical_unit: None,
+ display_price: None,
+ display_price_unit: None,
+ });
+ *order_index += 1;
+ let index = bins.len() - 1;
+ &mut bins[index]
+}
+
+fn build_bins(mut drafts: Vec<BinDraft>) -> Result<Vec<RadrootsListingBin>, EventParseError> {
+ drafts.sort_by_key(|draft| draft.order_index);
+ let mut bins = Vec::with_capacity(drafts.len());
+ for draft in drafts {
+ let quantity = draft
+ .quantity
+ .ok_or(EventParseError::MissingTag(TAG_RADROOTS_BIN))?;
+ let price = draft
+ .price_per_canonical_unit
+ .ok_or(EventParseError::MissingTag(TAG_RADROOTS_PRICE))?;
+ if quantity.unit != price.quantity.unit {
+ return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
+ }
+ bins.push(RadrootsListingBin {
+ bin_id: draft.bin_id,
+ quantity,
+ price_per_canonical_unit: price,
+ display_amount: draft.display_amount,
+ display_unit: draft.display_unit,
+ display_label: draft.display_label,
+ display_price: draft.display_price,
+ display_price_unit: draft.display_price_unit,
+ });
+ }
+ Ok(bins)
}
diff --git a/crates/events-codec/src/listing/encode.rs b/crates/events-codec/src/listing/encode.rs
@@ -1,15 +1,19 @@
#[cfg(not(feature = "std"))]
-use alloc::string::String;
-#[cfg(not(feature = "std"))]
-use alloc::vec::Vec;
+use alloc::{
+ format,
+ string::{String, ToString},
+ vec::Vec,
+};
#[cfg(feature = "serde_json")]
-use radroots_events::kinds::KIND_LISTING;
+use radroots_events::kinds::{KIND_LISTING, is_listing_kind};
use radroots_events::listing::RadrootsListing;
use crate::error::EventEncodeError;
use crate::listing::tags::listing_tags;
#[cfg(feature = "serde_json")]
+use crate::listing::tags::listing_tags_full;
+#[cfg(feature = "serde_json")]
use crate::wire::WireEventParts;
#[cfg(feature = "serde_json")]
@@ -29,11 +33,32 @@ pub fn to_wire_parts_with_kind(
listing: &RadrootsListing,
kind: u32,
) -> Result<WireEventParts, EventEncodeError> {
- let tags = listing_build_tags(listing)?;
- let content = serde_json::to_string(listing).map_err(|_| EventEncodeError::Json)?;
+ if !is_listing_kind(kind) {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ let tags = listing_tags_full(listing)?;
+ let content = listing_markdown_content(listing);
Ok(WireEventParts {
kind,
content,
tags,
})
}
+
+#[cfg(feature = "serde_json")]
+fn listing_markdown_content(listing: &RadrootsListing) -> String {
+ let title = listing.product.title.trim();
+ let summary = listing
+ .product
+ .summary
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty());
+
+ match (title.is_empty(), summary) {
+ (false, Some(summary)) => format!("# {title}\n\n{summary}"),
+ (false, None) => format!("# {title}"),
+ (true, Some(summary)) => summary.to_string(),
+ (true, None) => String::new(),
+ }
+}
diff --git a/crates/events-codec/src/listing/tags.rs b/crates/events-codec/src/listing/tags.rs
@@ -44,7 +44,7 @@ const TAG_DD_LAT: &str = "dd.lat";
const TAG_DD_LON: &str = "dd.lon";
const TAG_INVENTORY: &str = "inventory";
const TAG_DELIVERY: &str = "delivery";
-const TAG_PUBLISHED_AT: &str = "published_at";
+const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start";
const TAG_STATUS: &str = "status";
const TAG_EXPIRES_AT: &str = "expires_at";
const TAG_P: &str = "p";
@@ -167,8 +167,10 @@ pub fn listing_tags_with_options(
let bin_tag = tag_listing_bin(bin)?;
tags.push(bin_tag);
tags.push(price_tag);
- let total = bin_total_price(bin)?;
- tags.push(tag_listing_price_generic(&total));
+ if bin.bin_id == listing.primary_bin_id {
+ let total = bin_total_price(bin)?;
+ tags.push(tag_listing_price_generic(&total));
+ }
}
#[cfg(feature = "serde_json")]
@@ -200,7 +202,10 @@ pub fn listing_tags_with_options(
}
RadrootsListingAvailability::Window { start, end } => {
if let Some(start) = start {
- tags.push(vec![TAG_PUBLISHED_AT.to_string(), start.to_string()]);
+ tags.push(vec![
+ TAG_RADROOTS_AVAILABILITY_START.to_string(),
+ start.to_string(),
+ ]);
}
if let Some(end) = end {
tags.push(vec![TAG_EXPIRES_AT.to_string(), end.to_string()]);
@@ -1284,7 +1289,7 @@ mod tests {
assert!(find_tag(&tags, "radroots:price").is_some());
assert!(find_tag(&tags, "price").is_some());
assert!(find_tag(&tags, "inventory").is_some());
- assert!(find_tag(&tags, "published_at").is_some());
+ assert!(find_tag(&tags, "radroots:availability_start").is_some());
assert!(find_tag(&tags, "expires_at").is_some());
assert!(find_tag(&tags, "delivery").is_some());
assert!(find_tag(&tags, "location").is_some());
@@ -1330,7 +1335,7 @@ mod tests {
)
.expect("availability option without value");
assert!(find_tag(&no_availability_tags, "status").is_none());
- assert!(find_tag(&no_availability_tags, "published_at").is_none());
+ assert!(find_tag(&no_availability_tags, "radroots:availability_start").is_none());
assert!(find_tag(&no_availability_tags, "expires_at").is_none());
let mut empty_window_availability = base_listing();
@@ -1347,7 +1352,7 @@ mod tests {
},
)
.expect("availability window without bounds");
- assert!(find_tag(&empty_window_tags, "published_at").is_none());
+ assert!(find_tag(&empty_window_tags, "radroots:availability_start").is_none());
assert!(find_tag(&empty_window_tags, "expires_at").is_none());
let mut no_delivery = base_listing();
diff --git a/crates/events-codec/tests/domain_encode_non_serde.rs b/crates/events-codec/tests/domain_encode_non_serde.rs
@@ -627,7 +627,7 @@ fn listing_encode_paths() {
&& tag.get(1).map(|v| v.as_str()) == Some("12")
}));
assert!(full_tags.iter().any(|tag| {
- tag.first().map(|v| v.as_str()) == Some("published_at")
+ tag.first().map(|v| v.as_str()) == Some("radroots:availability_start")
&& tag.get(1).map(|v| v.as_str()) == Some("1")
}));
assert!(full_tags.iter().any(|tag| {
@@ -789,7 +789,7 @@ fn listing_encode_paths() {
assert!(
!no_availability_tags
.iter()
- .any(|tag| tag.first().map(|v| v.as_str()) == Some("published_at"))
+ .any(|tag| { tag.first().map(|v| v.as_str()) == Some("radroots:availability_start") })
);
let mut listing_pickup = sample_listing();
diff --git a/crates/events-codec/tests/listing.rs b/crates/events-codec/tests/listing.rs
@@ -7,7 +7,7 @@ use radroots_core::{
};
use radroots_events::tags::TAG_D;
use radroots_events::{
- kinds::{KIND_LISTING, KIND_POST},
+ kinds::{KIND_LISTING, KIND_LISTING_DRAFT, KIND_POST},
listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingImage,
@@ -17,7 +17,9 @@ use radroots_events::{
};
use radroots_events_codec::error::{EventEncodeError, EventParseError};
use radroots_events_codec::listing::decode::listing_from_event;
-use radroots_events_codec::listing::encode::{listing_build_tags, to_wire_parts};
+use radroots_events_codec::listing::encode::{
+ listing_build_tags, to_wire_parts, to_wire_parts_with_kind,
+};
use radroots_events_codec::listing::tags::{
ListingTagOptions, listing_tags_full, listing_tags_with_options,
};
@@ -161,6 +163,8 @@ fn listing_roundtrip_from_event() {
let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
let parts = to_wire_parts(&listing).unwrap();
+ assert_eq!(parts.content, "# Widget");
+
let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
assert_eq!(decoded.d_tag, listing.d_tag);
assert_eq!(decoded.product.key, listing.product.key);
@@ -170,63 +174,69 @@ fn listing_roundtrip_from_event() {
}
#[test]
-fn listing_from_event_fills_missing_d_tag() {
- let listing = sample_listing("");
- let content = serde_json::to_string(&listing).unwrap();
- let tags = vec![
- vec![TAG_D.to_string(), "FAAAAAAAAAAAAAAAAAAAAA".to_string()],
- vec!["p".to_string(), "farm_pubkey".to_string()],
- vec![
- "a".to_string(),
- "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA".to_string(),
- ],
- ];
-
- let decoded = listing_from_event(KIND_LISTING, &tags, &content).unwrap();
- assert_eq!(decoded.d_tag, "FAAAAAAAAAAAAAAAAAAAAA");
+fn listing_from_event_reconstructs_from_tags_with_markdown_content() {
+ let listing = sample_listing_full("FAAAAAAAAAAAAAAAAAAAAA");
+ let tags = listing_build_tags(&listing).unwrap();
+
+ let decoded = listing_from_event(KIND_LISTING, &tags, "### Markdown listing").unwrap();
+ assert_eq!(decoded.d_tag, listing.d_tag);
+ assert_eq!(decoded.product.summary, listing.product.summary);
+ assert_eq!(decoded.primary_bin_id, listing.primary_bin_id);
+ assert_eq!(
+ decoded
+ .location
+ .as_ref()
+ .map(|location| location.primary.as_str()),
+ Some("Moyobamba")
+ );
}
#[test]
-fn listing_from_event_rejects_mismatched_d_tag() {
- let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
- let content = serde_json::to_string(&listing).unwrap();
- let tags = vec![
- vec![TAG_D.to_string(), "AAAAAAAAAAAAAAAAAAAAAQ".to_string()],
- vec!["p".to_string(), "farm_pubkey".to_string()],
- vec![
- "a".to_string(),
- "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA".to_string(),
- ],
- ];
-
- let err = listing_from_event(KIND_LISTING, &tags, &content).unwrap_err();
+fn listing_from_event_rejects_invalid_d_tag() {
+ let mut tags = listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap();
+ let d_tag = tags
+ .iter_mut()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D))
+ .expect("d tag");
+ d_tag[1] = "invalid:tag".to_string();
+
+ let err = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap_err();
assert!(matches!(err, EventParseError::InvalidTag(TAG_D)));
}
#[test]
fn listing_from_event_rejects_wrong_kind() {
- let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
- let content = serde_json::to_string(&listing).unwrap();
- let tags = vec![
- vec![TAG_D.to_string(), "AAAAAAAAAAAAAAAAAAAAAg".to_string()],
- vec!["p".to_string(), "farm_pubkey".to_string()],
- vec![
- "a".to_string(),
- "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA".to_string(),
- ],
- ];
-
- let err = listing_from_event(KIND_POST, &tags, &content).unwrap_err();
+ let tags = listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap();
+
+ let err = listing_from_event(KIND_POST, &tags, "# Widget").unwrap_err();
assert!(matches!(
err,
EventParseError::InvalidKind {
- expected: "30402",
+ expected: "30402 or 30403",
got: KIND_POST
}
));
}
#[test]
+fn draft_listing_roundtrip_from_event() {
+ let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
+ let parts = to_wire_parts_with_kind(&listing, KIND_LISTING_DRAFT).unwrap();
+
+ let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert_eq!(parts.kind, KIND_LISTING_DRAFT);
+ assert_eq!(parts.content, "# Widget");
+ assert_eq!(decoded.d_tag, listing.d_tag);
+}
+
+#[test]
+fn to_wire_parts_rejects_non_listing_kind() {
+ let err =
+ to_wire_parts_with_kind(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg"), KIND_POST).unwrap_err();
+ assert!(matches!(err, EventEncodeError::InvalidKind(KIND_POST)));
+}
+
+#[test]
fn listing_build_tags_includes_listing_fields() {
let listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAg");
let tags = listing_build_tags(&listing).unwrap();
@@ -336,6 +346,48 @@ fn listing_build_tags_includes_listing_fields() {
}
#[test]
+fn listing_tags_full_uses_single_generic_price_for_primary_bin() {
+ let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAw");
+ listing.bins.push(RadrootsListingBin {
+ bin_id: "bin-2".to_string(),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from_str("500").unwrap(),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from_str("0.02").unwrap(),
+ RadrootsCoreCurrency::USD,
+ ),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG),
+ ),
+ display_amount: Some(RadrootsCoreDecimal::from(500u32)),
+ display_unit: Some(RadrootsCoreUnit::MassG),
+ display_label: Some("sample".to_string()),
+ display_price: Some(RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from_str("10").unwrap(),
+ RadrootsCoreCurrency::USD,
+ )),
+ display_price_unit: Some(RadrootsCoreUnit::MassG),
+ });
+
+ let tags = listing_tags_full(&listing).unwrap();
+ let generic_price_tags: Vec<&Vec<String>> = tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) == Some("price"))
+ .collect();
+ assert_eq!(generic_price_tags.len(), 1);
+ assert_eq!(
+ generic_price_tags[0].get(1).map(|value| value.as_str()),
+ Some("10")
+ );
+ assert_eq!(
+ generic_price_tags[0].get(2).map(|value| value.as_str()),
+ Some("USD")
+ );
+}
+
+#[test]
fn listing_tags_full_includes_trade_fields() {
let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
let inventory = RadrootsCoreDecimal::from_str("12.5").unwrap();
@@ -354,7 +406,7 @@ fn listing_tags_full_includes_trade_fields() {
&& t.get(1).map(|s| s.as_str()) == Some(inventory_value.as_str())
}));
assert!(tags.iter().any(|t| {
- t.get(0).map(|s| s.as_str()) == Some("published_at")
+ t.get(0).map(|s| s.as_str()) == Some("radroots:availability_start")
&& t.get(1).map(|s| s.as_str()) == Some("1730000000")
}));
assert!(tags.iter().any(|t| {
diff --git a/crates/events/src/kinds.rs b/crates/events/src/kinds.rs
@@ -48,6 +48,7 @@ pub const KIND_RESOURCE_HARVEST_CAP: u32 = 30371;
pub const KIND_ACCOUNT_CLAIM: u32 = 30380;
pub const KIND_APP_DATA: u32 = 30078;
pub const KIND_LISTING: u32 = 30402;
+pub const KIND_LISTING_DRAFT: u32 = 30403;
pub const KIND_APPLICATION_HANDLER: u32 = 31990;
pub const KIND_TRADE_LISTING_VALIDATE_REQ: u32 = 5321;
@@ -128,6 +129,11 @@ pub const KIND_JOB_RESULT_MAX: u32 = 6999;
pub const KIND_JOB_FEEDBACK: u32 = 7000;
#[inline]
+pub const fn is_listing_kind(kind: u32) -> bool {
+ matches!(kind, KIND_LISTING | KIND_LISTING_DRAFT)
+}
+
+#[inline]
pub const fn is_trade_service_request_kind(kind: u32) -> bool {
kind == KIND_TRADE_LISTING_VALIDATE_REQ
}
diff --git a/crates/trade/src/listing/codec.rs b/crates/trade/src/listing/codec.rs
@@ -34,7 +34,7 @@ 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_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start";
const TAG_STATUS: &str = "status";
const TAG_EXPIRES_AT: &str = "expires_at";
const TAG_P: &str = "p";
@@ -379,12 +379,14 @@ fn listing_from_tags(
.ok_or_else(|| TradeListingParseError::InvalidTag(TAG_INVENTORY.to_string()))?;
inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?);
}
- TAG_PUBLISHED_AT => {
+ TAG_RADROOTS_AVAILABILITY_START => {
let value = tag.get(1).ok_or_else(|| {
- TradeListingParseError::InvalidTag(TAG_PUBLISHED_AT.to_string())
+ TradeListingParseError::InvalidTag(TAG_RADROOTS_AVAILABILITY_START.to_string())
})?;
availability_start = Some(value.parse::<u64>().map_err(|_| {
- TradeListingParseError::InvalidNumber(TAG_PUBLISHED_AT.to_string())
+ TradeListingParseError::InvalidNumber(
+ TAG_RADROOTS_AVAILABILITY_START.to_string(),
+ )
})?);
}
TAG_EXPIRES_AT => {
@@ -1052,7 +1054,7 @@ mod tests {
]);
tags.push(vec![TAG_GEOHASH.into(), "u6se".into()]);
tags.push(vec![TAG_INVENTORY.into(), "8".into()]);
- tags.push(vec![TAG_PUBLISHED_AT.into(), "10".into()]);
+ tags.push(vec![TAG_RADROOTS_AVAILABILITY_START.into(), "10".into()]);
tags.push(vec![TAG_EXPIRES_AT.into(), "20".into()]);
tags.push(vec![TAG_DELIVERY.into(), "other".into(), "drone".into()]);
tags.push(vec![
@@ -1479,7 +1481,7 @@ mod tests {
assert_eq!(parse_error_tag(err), TAG_INVENTORY.to_string());
let mut tags = base_trade_tags();
- tags.push(vec![TAG_PUBLISHED_AT.into(), "bad".into()]);
+ tags.push(vec![TAG_RADROOTS_AVAILABILITY_START.into(), "bad".into()]);
let err = listing_from_tags(
&tags,
listing_d_tag(),
@@ -1489,7 +1491,10 @@ mod tests {
None,
)
.unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_PUBLISHED_AT.to_string());
+ assert_eq!(
+ parse_error_tag(err),
+ TAG_RADROOTS_AVAILABILITY_START.to_string()
+ );
let mut tags = base_trade_tags();
tags.push(vec![TAG_EXPIRES_AT.into(), "bad".into()]);
@@ -1518,7 +1523,7 @@ mod tests {
assert_eq!(parse_error_tag(err), TAG_RADROOTS_DISCOUNT.to_string());
let mut tags = base_trade_tags();
- tags.push(vec![TAG_PUBLISHED_AT.into()]);
+ tags.push(vec![TAG_RADROOTS_AVAILABILITY_START.into()]);
let err = listing_from_tags(
&tags,
listing_d_tag(),
@@ -1528,7 +1533,10 @@ mod tests {
None,
)
.unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_PUBLISHED_AT.to_string());
+ assert_eq!(
+ parse_error_tag(err),
+ TAG_RADROOTS_AVAILABILITY_START.to_string()
+ );
let mut tags = base_trade_tags();
tags.push(vec![TAG_EXPIRES_AT.into()]);
diff --git a/crates/trade/src/listing/projection.rs b/crates/trade/src/listing/projection.rs
@@ -10,7 +10,7 @@ use std::collections::BTreeMap;
use radroots_core::{RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountValue};
use radroots_events::{
RadrootsNostrEvent,
- kinds::KIND_LISTING,
+ kinds::{KIND_LISTING, is_listing_kind},
listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingImage,
@@ -539,12 +539,14 @@ impl RadrootsTradeListingProjection {
pub fn from_listing_event(
event: &RadrootsNostrEvent,
) -> Result<Self, RadrootsTradeProjectionError> {
- if event.kind != KIND_LISTING {
+ if !is_listing_kind(event.kind) {
return Err(RadrootsTradeProjectionError::InvalidListingKind { kind: event.kind });
}
let listing = listing_from_event_parts(&event.tags, &event.content)
.map_err(|error| RadrootsTradeProjectionError::InvalidListingContract { error })?;
- Self::from_listing_contract(event.author.clone(), &listing)
+ let mut projection = Self::from_listing_contract(event.author.clone(), &listing)?;
+ projection.listing_addr = format!("{}:{}:{}", event.kind, event.author, listing.d_tag);
+ Ok(projection)
}
pub fn from_listing_contract(
diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs
@@ -8,7 +8,7 @@ use radroots_core::{
};
use radroots_events::{
RadrootsNostrEvent,
- kinds::KIND_LISTING,
+ kinds::is_listing_kind,
listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod,
RadrootsListingLocation,
@@ -50,7 +50,7 @@ pub struct RadrootsTradeListing {
pub fn validate_listing_event(
event: &RadrootsNostrEvent,
) -> Result<RadrootsTradeListing, TradeListingValidationError> {
- if event.kind != KIND_LISTING {
+ if !is_listing_kind(event.kind) {
return Err(TradeListingValidationError::InvalidKind { kind: event.kind });
}
@@ -63,7 +63,7 @@ pub fn validate_listing_event(
return Err(TradeListingValidationError::InvalidSeller);
}
let listing_addr = TradeListingAddress {
- kind: KIND_LISTING,
+ kind: event.kind as _,
seller_pubkey: seller_pubkey.clone(),
listing_id: listing_id.clone(),
}
@@ -179,7 +179,7 @@ mod tests {
};
use radroots_events::{
RadrootsNostrEvent,
- kinds::KIND_LISTING,
+ kinds::{KIND_LISTING, KIND_LISTING_DRAFT},
listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation,
@@ -282,6 +282,18 @@ mod tests {
}
#[test]
+ fn validate_draft_listing_ok() {
+ let listing = base_listing();
+ let mut event = base_event(&listing);
+ event.kind = KIND_LISTING_DRAFT;
+ let validated = validate_listing_event(&event).expect("draft listing");
+ assert_eq!(
+ validated.listing_addr,
+ format!("30403:seller:{}", listing.d_tag)
+ );
+ }
+
+ #[test]
fn validate_listing_rejects_missing_d_tag() {
let listing = base_listing();
let mut event = base_event(&listing);