lib

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

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:
Mcrates/events-codec/Cargo.toml | 2+-
Mcrates/events-codec/src/listing/decode.rs | 551++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/events-codec/src/listing/encode.rs | 37+++++++++++++++++++++++++++++++------
Mcrates/events-codec/src/listing/tags.rs | 19++++++++++++-------
Mcrates/events-codec/tests/domain_encode_non_serde.rs | 4++--
Mcrates/events-codec/tests/listing.rs | 138++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/events/src/kinds.rs | 6++++++
Mcrates/trade/src/listing/codec.rs | 26+++++++++++++++++---------
Mcrates/trade/src/listing/projection.rs | 8+++++---
Mcrates/trade/src/listing/validation.rs | 20++++++++++++++++----
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);