lib

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

commit d85a5f64f1c25835b1ce46bf596dfece18dd18c0
parent 927e08fac32a77b9e104e6d2203b3c0b61dc28c3
Author: triesap <tyson@radroots.org>
Date:   Sat,  3 Jan 2026 15:20:13 +0000

events: add resource area and harvest cap event support


- Add resource_area and resource_cap modules with encode/decode and tests
- Parse and emit radroots:resource_area and radroots:plot listing tags
- Extend listing models/bindings with optional resource_area and plot refs
- Fix nostr client timestamp handling and event stream error tolerance

Diffstat:
Mevents-codec/src/farm/mod.rs | 2++
Mevents-codec/src/lib.rs | 2++
Mevents-codec/src/listing/decode.rs | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mevents-codec/src/listing/tags.rs | 46+++++++++++++++++++++++++++++++++++++++++++++-
Aevents-codec/src/resource_area/decode.rs | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/resource_area/encode.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/resource_area/list_sets.rs | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/resource_area/mod.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/resource_cap/decode.rs | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/resource_cap/encode.rs | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/resource_cap/mod.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Mevents-codec/src/tag_builders.rs | 20++++++++++++++++++++
Mevents-codec/tests/listing.rs | 4++++
Mevents/bindings/ts/src/kinds.ts | 2++
Mevents/bindings/ts/src/schemas.ts | 56++++++++++++++++++++------------------------------------
Mevents/bindings/ts/src/types.ts | 22+++++++++++++++++++++-
Mevents/src/kinds.rs | 4++++
Mevents/src/lib.rs | 2++
Mevents/src/listing.rs | 12++++++++++++
Mevents/src/plot.rs | 9+++++++++
Aevents/src/resource_area.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents/src/resource_cap.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnet-core/src/nostr_client/manager.rs | 10+++++++---
Mtrade/bindings/ts/package.json | 2+-
Mtrade/bindings/ts/src/types.ts | 4++--
Mtrade/src/listing/codec.rs | 145++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mtrade/src/listing/validation.rs | 2++
27 files changed, 1241 insertions(+), 81 deletions(-)

diff --git a/events-codec/src/farm/mod.rs b/events-codec/src/farm/mod.rs @@ -178,6 +178,8 @@ mod tests { display_price: None, display_price_unit: None, }], + resource_area: None, + plot: None, discounts: None, inventory_available: None, availability: None, diff --git a/events-codec/src/lib.rs b/events-codec/src/lib.rs @@ -16,6 +16,8 @@ pub mod app_data; pub mod document; pub mod coop; pub mod farm; +pub mod resource_area; +pub mod resource_cap; pub mod gift_wrap; pub mod message; pub mod message_file; diff --git a/events-codec/src/listing/decode.rs b/events-codec/src/listing/decode.rs @@ -5,9 +5,11 @@ use alloc::{string::{String, ToString}, vec::Vec}; use radroots_events::{ RadrootsNostrEvent, - kinds::KIND_FARM, + kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA}, kinds::KIND_LISTING, listing::{RadrootsListing, RadrootsListingEventIndex, RadrootsListingEventMetadata, RadrootsListingFarmRef}, + plot::RadrootsPlotRef, + resource_area::RadrootsResourceAreaRef, tags::TAG_D, }; @@ -16,6 +18,8 @@ use crate::error::EventParseError; const DEFAULT_KIND: u32 = KIND_LISTING; const TAG_A: &str = "a"; const TAG_P: &str = "p"; +const TAG_RADROOTS_RESOURCE_AREA: &str = "radroots:resource_area"; +const TAG_RADROOTS_PLOT: &str = "radroots:plot"; fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { let tag = tags @@ -33,49 +37,114 @@ 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)) { + let value = tag + .get(1) + .map(|s| s.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()) + .ok_or(EventParseError::InvalidTag(TAG_A))?; + if kind != KIND_FARM { + continue; + } + let pubkey = parts + .next() + .ok_or(EventParseError::InvalidTag(TAG_A))? + .to_string(); + let d_tag = parts + .next() + .ok_or(EventParseError::InvalidTag(TAG_A))? + .to_string(); + if pubkey.trim().is_empty() || d_tag.trim().is_empty() { + return Err(EventParseError::InvalidTag(TAG_A)); + } + return Ok(RadrootsListingFarmRef { pubkey, d_tag }); + } + Err(EventParseError::MissingTag(TAG_A)) +} + +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)) + .ok_or(EventParseError::MissingTag(TAG_P))?; + let value = tag + .get(1) + .map(|s| s.to_string()) + .ok_or(EventParseError::InvalidTag(TAG_P))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(TAG_P)); + } + Ok(value) +} + +fn parse_resource_area(tags: &[Vec<String>]) -> Result<Option<RadrootsResourceAreaRef>, EventParseError> { let tag = tags .iter() - .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_A)) - .ok_or(EventParseError::MissingTag(TAG_A))?; + .find(|t| t.get(0).map(|s| s.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()) - .ok_or(EventParseError::InvalidTag(TAG_A))?; + .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()) - .ok_or(EventParseError::InvalidTag(TAG_A))?; - if kind != KIND_FARM { - return Err(EventParseError::InvalidTag(TAG_A)); + .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?; + if kind != KIND_RESOURCE_AREA { + return Err(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA)); } let pubkey = parts .next() - .ok_or(EventParseError::InvalidTag(TAG_A))? + .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))? .to_string(); let d_tag = parts .next() - .ok_or(EventParseError::InvalidTag(TAG_A))? + .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))? .to_string(); if pubkey.trim().is_empty() || d_tag.trim().is_empty() { - return Err(EventParseError::InvalidTag(TAG_A)); + return Err(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA)); } - Ok(RadrootsListingFarmRef { pubkey, d_tag }) + Ok(Some(RadrootsResourceAreaRef { pubkey, d_tag })) } -fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, EventParseError> { +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_P)) - .ok_or(EventParseError::MissingTag(TAG_P))?; + .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_RADROOTS_PLOT)); + let Some(tag) = tag else { + return Ok(None); + }; let value = tag .get(1) .map(|s| s.to_string()) - .ok_or(EventParseError::InvalidTag(TAG_P))?; - if value.trim().is_empty() { - return Err(EventParseError::InvalidTag(TAG_P)); + .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?; + let mut parts = value.splitn(3, ':'); + let kind = parts + .next() + .and_then(|v| v.parse::<u32>().ok()) + .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?; + if kind != KIND_PLOT { + return Err(EventParseError::InvalidTag(TAG_RADROOTS_PLOT)); } - Ok(value) + let pubkey = parts + .next() + .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))? + .to_string(); + let d_tag = parts + .next() + .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))? + .to_string(); + if pubkey.trim().is_empty() || d_tag.trim().is_empty() { + return Err(EventParseError::InvalidTag(TAG_RADROOTS_PLOT)); + } + Ok(Some(RadrootsPlotRef { pubkey, d_tag })) } pub fn listing_from_event( @@ -95,6 +164,8 @@ pub fn listing_from_event( 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"))?; @@ -113,6 +184,28 @@ pub fn listing_from_event( return Err(EventParseError::InvalidTag(TAG_P)); } + 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)); + } + } + } + } + + 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)); + } + } + } + } + Ok(listing) } diff --git a/events-codec/src/listing/tags.rs b/events-codec/src/listing/tags.rs @@ -11,7 +11,9 @@ use radroots_events::listing::{ RadrootsListingBin, RadrootsListingImage, RadrootsListingLocation, RadrootsListingStatus, }; -use radroots_events::kinds::KIND_FARM; +use radroots_events::plot::RadrootsPlotRef; +use radroots_events::resource_area::RadrootsResourceAreaRef; +use radroots_events::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA}; use radroots_events::tags::TAG_D; use crate::error::EventEncodeError; @@ -21,6 +23,8 @@ 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"; @@ -104,6 +108,12 @@ pub fn listing_tags_with_options( let mut tags: Vec<Vec<String>> = Vec::new(); tags.push(vec![TAG_D.to_string(), d_tag.to_string()]); push_farm_tags(&mut tags, &listing.farm)?; + if let Some(resource_area) = listing.resource_area.as_ref() { + tags.push(tag_listing_resource_area(resource_area)?); + } + if let Some(plot) = listing.plot.as_ref() { + tags.push(tag_listing_plot(plot)?); + } let product = &listing.product; push_tag_value(&mut tags, "key", &product.key); @@ -244,6 +254,40 @@ fn push_farm_tags( Ok(()) } +fn tag_listing_resource_area( + area: &RadrootsResourceAreaRef, +) -> Result<Vec<String>, EventEncodeError> { + if area.pubkey.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("resource_area.pubkey")); + } + if area.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("resource_area.d_tag")); + } + let mut address = String::new(); + address.push_str(&KIND_RESOURCE_AREA.to_string()); + address.push(':'); + address.push_str(&area.pubkey); + address.push(':'); + address.push_str(&area.d_tag); + Ok(vec![TAG_RADROOTS_RESOURCE_AREA.to_string(), address]) +} + +fn tag_listing_plot(plot: &RadrootsPlotRef) -> Result<Vec<String>, EventEncodeError> { + if plot.pubkey.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("plot.pubkey")); + } + if plot.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("plot.d_tag")); + } + let mut address = String::new(); + address.push_str(&KIND_PLOT.to_string()); + address.push(':'); + address.push_str(&plot.pubkey); + address.push(':'); + address.push_str(&plot.d_tag); + Ok(vec![TAG_RADROOTS_PLOT.to_string(), address]) +} + fn tag_listing_bin(bin: &RadrootsListingBin) -> Result<Vec<String>, EventEncodeError> { if bin.bin_id.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("bin_id")); diff --git a/events-codec/src/resource_area/decode.rs b/events-codec/src/resource_area/decode.rs @@ -0,0 +1,106 @@ +#![cfg(feature = "serde_json")] + +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + kinds::KIND_RESOURCE_AREA, + resource_area::{RadrootsResourceArea, RadrootsResourceAreaEventIndex, RadrootsResourceAreaEventMetadata}, + tags::TAG_D, +}; + +use crate::error::EventParseError; + +const DEFAULT_KIND: u32 = KIND_RESOURCE_AREA; + +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)) + .ok_or(EventParseError::MissingTag(TAG_D))?; + let value = tag + .get(1) + .map(|s| s.to_string()) + .ok_or(EventParseError::InvalidTag(TAG_D))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(TAG_D)); + } + Ok(value) +} + +pub fn resource_area_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsResourceArea, EventParseError> { + if kind != DEFAULT_KIND { + return Err(EventParseError::InvalidKind { + expected: "30370", + got: kind, + }); + } + if content.trim().is_empty() { + return Err(EventParseError::InvalidJson("content")); + } + let d_tag = parse_d_tag(tags)?; + let mut area: RadrootsResourceArea = + serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; + + if area.d_tag.trim().is_empty() { + area.d_tag = d_tag; + } else if area.d_tag != d_tag { + return Err(EventParseError::InvalidTag(TAG_D)); + } + + Ok(area) +} + +pub fn metadata_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsResourceAreaEventMetadata, EventParseError> { + let area = resource_area_from_event(kind, &tags, &content)?; + Ok(RadrootsResourceAreaEventMetadata { + id, + author, + published_at, + kind, + area, + }) +} + +pub fn index_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsResourceAreaEventIndex, EventParseError> { + let metadata = metadata_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsResourceAreaEventIndex { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + metadata, + }) +} diff --git a/events-codec/src/resource_area/encode.rs b/events-codec/src/resource_area/encode.rs @@ -0,0 +1,93 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; + +use radroots_events::{ + kinds::KIND_RESOURCE_AREA, + resource_area::{RadrootsResourceArea, RadrootsResourceAreaRef}, + tags::TAG_D, +}; + +use crate::error::EventEncodeError; + +#[cfg(feature = "serde_json")] +use crate::wire::WireEventParts; + +const TAG_T: &str = "t"; +const TAG_G: &str = "g"; +const TAG_A: &str = "a"; +const TAG_P: &str = "p"; + +fn push_tag(tags: &mut Vec<Vec<String>>, key: &str, value: &str) { + let mut tag = Vec::with_capacity(2); + tag.push(key.to_string()); + tag.push(value.to_string()); + tags.push(tag); +} + +fn resource_area_address(area: &RadrootsResourceAreaRef) -> Result<String, EventEncodeError> { + if area.pubkey.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("resource_area.pubkey")); + } + if area.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("resource_area.d_tag")); + } + let mut addr = String::new(); + addr.push_str(&KIND_RESOURCE_AREA.to_string()); + addr.push(':'); + addr.push_str(&area.pubkey); + addr.push(':'); + addr.push_str(&area.d_tag); + Ok(addr) +} + +pub fn resource_area_build_tags( + area: &RadrootsResourceArea, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + if area.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("d_tag")); + } + if area.name.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("name")); + } + let geohash = area.location.gcs.geohash.trim(); + if geohash.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("location.gcs.geohash")); + } + let mut tags = Vec::new(); + push_tag(&mut tags, TAG_D, &area.d_tag); + if let Some(items) = area.tags.as_ref() { + for item in items.iter().filter(|v| !v.trim().is_empty()) { + push_tag(&mut tags, TAG_T, item); + } + } + push_tag(&mut tags, TAG_G, geohash); + Ok(tags) +} + +pub fn resource_area_ref_tags( + area: &RadrootsResourceAreaRef, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let addr = resource_area_address(area)?; + let mut tags = Vec::with_capacity(2); + push_tag(&mut tags, TAG_P, &area.pubkey); + push_tag(&mut tags, TAG_A, &addr); + Ok(tags) +} + +#[cfg(feature = "serde_json")] +pub fn to_wire_parts(area: &RadrootsResourceArea) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind(area, KIND_RESOURCE_AREA) +} + +#[cfg(feature = "serde_json")] +pub fn to_wire_parts_with_kind( + area: &RadrootsResourceArea, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != KIND_RESOURCE_AREA { + return Err(EventEncodeError::InvalidKind(kind)); + } + let tags = resource_area_build_tags(area)?; + let content = serde_json::to_string(area).map_err(|_| EventEncodeError::Json)?; + Ok(WireEventParts { kind, content, tags }) +} diff --git a/events-codec/src/resource_area/list_sets.rs b/events-codec/src/resource_area/list_sets.rs @@ -0,0 +1,150 @@ +#![forbid(unsafe_code)] + +#[cfg(not(feature = "std"))] +use alloc::{format, string::{String, ToString}, vec, vec::Vec}; + +use radroots_events::farm::RadrootsFarmRef; +use radroots_events::kinds::{KIND_FARM, KIND_PLOT}; +use radroots_events::list::RadrootsListEntry; +use radroots_events::list_set::RadrootsListSet; +use radroots_events::plot::RadrootsPlotRef; + +use crate::error::EventEncodeError; + +fn resource_list_set_id(area_id: &str, suffix: &str) -> Result<String, EventEncodeError> { + let area_id = area_id.trim(); + if area_id.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("area_id")); + } + if suffix.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("list_set_suffix")); + } + Ok(format!("resource:{area_id}:{suffix}")) +} + +fn list_entries<I, S>(tag: &str, values: I) -> Result<Vec<RadrootsListEntry>, EventEncodeError> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let mut entries = Vec::new(); + for value in values { + let value = value.as_ref().trim(); + if value.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("entry.values")); + } + entries.push(RadrootsListEntry { + tag: tag.to_string(), + values: vec![value.to_string()], + }); + } + Ok(entries) +} + +fn farm_address(farm: &RadrootsFarmRef) -> Result<String, EventEncodeError> { + if farm.pubkey.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("farm.pubkey")); + } + if farm.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("farm.d_tag")); + } + let mut addr = String::new(); + addr.push_str(&KIND_FARM.to_string()); + addr.push(':'); + addr.push_str(&farm.pubkey); + addr.push(':'); + addr.push_str(&farm.d_tag); + Ok(addr) +} + +fn plot_address(plot: &RadrootsPlotRef) -> Result<String, EventEncodeError> { + if plot.pubkey.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("plot.pubkey")); + } + if plot.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("plot.d_tag")); + } + let mut addr = String::new(); + addr.push_str(&KIND_PLOT.to_string()); + addr.push(':'); + addr.push_str(&plot.pubkey); + addr.push(':'); + addr.push_str(&plot.d_tag); + Ok(addr) +} + +pub fn resource_area_members_farms_list_set<I>( + area_id: &str, + farms: I, +) -> Result<RadrootsListSet, EventEncodeError> +where + I: IntoIterator<Item = RadrootsFarmRef>, +{ + let mut entries = Vec::new(); + for farm in farms { + let address = farm_address(&farm)?; + entries.push(RadrootsListEntry { + tag: "a".to_string(), + values: vec![address], + }); + entries.push(RadrootsListEntry { + tag: "p".to_string(), + values: vec![farm.pubkey], + }); + } + Ok(RadrootsListSet { + d_tag: resource_list_set_id(area_id, "members.farms")?, + content: String::new(), + entries, + title: None, + description: None, + image: None, + }) +} + +pub fn resource_area_members_plots_list_set<I>( + area_id: &str, + plots: I, +) -> Result<RadrootsListSet, EventEncodeError> +where + I: IntoIterator<Item = RadrootsPlotRef>, +{ + let mut entries = Vec::new(); + for plot in plots { + let address = plot_address(&plot)?; + entries.push(RadrootsListEntry { + tag: "a".to_string(), + values: vec![address], + }); + entries.push(RadrootsListEntry { + tag: "p".to_string(), + values: vec![plot.pubkey], + }); + } + Ok(RadrootsListSet { + d_tag: resource_list_set_id(area_id, "members.plots")?, + content: String::new(), + entries, + title: None, + description: None, + image: None, + }) +} + +pub fn resource_area_stewards_list_set<I, S>( + area_id: &str, + stewards: I, +) -> Result<RadrootsListSet, EventEncodeError> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + Ok(RadrootsListSet { + d_tag: resource_list_set_id(area_id, "members.stewards")?, + content: String::new(), + entries: list_entries("p", stewards)?, + title: None, + description: None, + image: None, + }) +} diff --git a/events-codec/src/resource_area/mod.rs b/events-codec/src/resource_area/mod.rs @@ -0,0 +1,120 @@ +#![forbid(unsafe_code)] + +pub mod encode; +pub mod decode; +pub mod list_sets; + +#[cfg(test)] +mod tests { + use radroots_events::farm::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; + use radroots_events::resource_area::{RadrootsResourceArea, RadrootsResourceAreaLocation, RadrootsResourceAreaRef}; + use crate::resource_area::encode::{resource_area_build_tags, resource_area_ref_tags}; + use crate::resource_area::list_sets::{ + resource_area_members_farms_list_set, + resource_area_members_plots_list_set, + resource_area_stewards_list_set, + }; + use radroots_events::farm::RadrootsFarmRef; + use radroots_events::plot::RadrootsPlotRef; + + fn sample_location() -> RadrootsResourceAreaLocation { + RadrootsResourceAreaLocation { + primary: None, + city: None, + region: None, + country: None, + gcs: RadrootsGcsLocation { + lat: -4.527, + lng: 129.898, + geohash: "pmb5v".to_string(), + point: RadrootsGeoJsonPoint { + r#type: "Point".to_string(), + coordinates: [129.898, -4.527], + }, + polygon: RadrootsGeoJsonPolygon { + r#type: "Polygon".to_string(), + coordinates: vec![vec![ + [129.898, -4.527], + [129.899, -4.527], + [129.899, -4.528], + [129.898, -4.527], + ]], + }, + accuracy: None, + altitude: None, + tag_0: None, + label: None, + area: None, + elevation: None, + soil: None, + climate: None, + gc_id: None, + gc_name: None, + gc_admin1_id: None, + gc_admin1_name: None, + gc_country_id: None, + gc_country_name: None, + }, + } + } + + #[test] + fn resource_area_tags_include_required_fields() { + let area = RadrootsResourceArea { + d_tag: "area-1".to_string(), + name: "Banda Grove".to_string(), + about: None, + location: sample_location(), + tags: Some(vec!["nutmeg".to_string()]), + }; + + let tags = resource_area_build_tags(&area).expect("tags"); + assert!(tags.iter().any(|tag| tag.get(0).map(|v| v.as_str()) == Some("d"))); + assert!(tags.iter().any(|tag| tag.get(0).map(|v| v.as_str()) == Some("g"))); + assert!(tags.iter().any(|tag| tag.get(0).map(|v| v.as_str()) == Some("t"))); + } + + #[test] + fn resource_area_ref_tags_include_p_and_a() { + let area_ref = RadrootsResourceAreaRef { + pubkey: "area_pubkey".to_string(), + d_tag: "area-1".to_string(), + }; + + let tags = resource_area_ref_tags(&area_ref).expect("ref tags"); + assert!(tags.iter().any(|tag| tag.get(0).map(|v| v.as_str()) == Some("p"))); + assert!(tags.iter().any(|tag| tag.get(0).map(|v| v.as_str()) == Some("a"))); + } + + #[test] + fn resource_area_list_sets_include_expected_tags() { + let farms = resource_area_members_farms_list_set( + "area-1", + [RadrootsFarmRef { + pubkey: "farm_pubkey".to_string(), + d_tag: "farm-1".to_string(), + }], + ) + .expect("farm members"); + assert_eq!(farms.d_tag, "resource:area-1:members.farms"); + assert!(farms.entries.iter().any(|entry| entry.tag == "a")); + assert!(farms.entries.iter().any(|entry| entry.tag == "p")); + + let plots = resource_area_members_plots_list_set( + "area-1", + [RadrootsPlotRef { + pubkey: "farm_pubkey".to_string(), + d_tag: "plot-1".to_string(), + }], + ) + .expect("plot members"); + assert_eq!(plots.d_tag, "resource:area-1:members.plots"); + assert!(plots.entries.iter().any(|entry| entry.tag == "a")); + assert!(plots.entries.iter().any(|entry| entry.tag == "p")); + + let stewards = resource_area_stewards_list_set("area-1", ["steward_pubkey"]) + .expect("stewards"); + assert_eq!(stewards.d_tag, "resource:area-1:members.stewards"); + assert!(stewards.entries.iter().any(|entry| entry.tag == "p")); + } +} diff --git a/events-codec/src/resource_cap/decode.rs b/events-codec/src/resource_cap/decode.rs @@ -0,0 +1,106 @@ +#![cfg(feature = "serde_json")] + +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + kinds::KIND_RESOURCE_HARVEST_CAP, + resource_cap::{RadrootsResourceHarvestCap, RadrootsResourceHarvestCapEventIndex, RadrootsResourceHarvestCapEventMetadata}, + tags::TAG_D, +}; + +use crate::error::EventParseError; + +const DEFAULT_KIND: u32 = KIND_RESOURCE_HARVEST_CAP; + +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)) + .ok_or(EventParseError::MissingTag(TAG_D))?; + let value = tag + .get(1) + .map(|s| s.to_string()) + .ok_or(EventParseError::InvalidTag(TAG_D))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(TAG_D)); + } + Ok(value) +} + +pub fn resource_harvest_cap_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsResourceHarvestCap, EventParseError> { + if kind != DEFAULT_KIND { + return Err(EventParseError::InvalidKind { + expected: "30371", + got: kind, + }); + } + if content.trim().is_empty() { + return Err(EventParseError::InvalidJson("content")); + } + let d_tag = parse_d_tag(tags)?; + let mut cap: RadrootsResourceHarvestCap = + serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; + + if cap.d_tag.trim().is_empty() { + cap.d_tag = d_tag; + } else if cap.d_tag != d_tag { + return Err(EventParseError::InvalidTag(TAG_D)); + } + + Ok(cap) +} + +pub fn metadata_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsResourceHarvestCapEventMetadata, EventParseError> { + let cap = resource_harvest_cap_from_event(kind, &tags, &content)?; + Ok(RadrootsResourceHarvestCapEventMetadata { + id, + author, + published_at, + kind, + cap, + }) +} + +pub fn index_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsResourceHarvestCapEventIndex, EventParseError> { + let metadata = metadata_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsResourceHarvestCapEventIndex { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + metadata, + }) +} diff --git a/events-codec/src/resource_cap/encode.rs b/events-codec/src/resource_cap/encode.rs @@ -0,0 +1,97 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; + +use radroots_events::{ + kinds::KIND_RESOURCE_AREA, + resource_cap::RadrootsResourceHarvestCap, + tags::TAG_D, +}; + +use crate::error::EventEncodeError; + +#[cfg(feature = "serde_json")] +use crate::wire::WireEventParts; +#[cfg(feature = "serde_json")] +use radroots_events::kinds::KIND_RESOURCE_HARVEST_CAP; + +const TAG_A: &str = "a"; +const TAG_P: &str = "p"; +const TAG_T: &str = "t"; +const TAG_KEY: &str = "key"; +const TAG_CATEGORY: &str = "category"; +const TAG_START: &str = "start"; +const TAG_END: &str = "end"; + +fn push_tag(tags: &mut Vec<Vec<String>>, key: &str, value: &str) { + let mut tag = Vec::with_capacity(2); + tag.push(key.to_string()); + tag.push(value.to_string()); + tags.push(tag); +} + +fn resource_area_address(cap: &RadrootsResourceHarvestCap) -> Result<String, EventEncodeError> { + let area = &cap.resource_area; + if area.pubkey.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("resource_area.pubkey")); + } + if area.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("resource_area.d_tag")); + } + let mut addr = String::new(); + addr.push_str(&KIND_RESOURCE_AREA.to_string()); + addr.push(':'); + addr.push_str(&area.pubkey); + addr.push(':'); + addr.push_str(&area.d_tag); + Ok(addr) +} + +pub fn resource_harvest_cap_build_tags( + cap: &RadrootsResourceHarvestCap, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + if cap.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("d_tag")); + } + if cap.product.key.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("product.key")); + } + let mut tags = Vec::new(); + push_tag(&mut tags, TAG_D, &cap.d_tag); + let addr = resource_area_address(cap)?; + push_tag(&mut tags, TAG_A, &addr); + push_tag(&mut tags, TAG_P, &cap.resource_area.pubkey); + push_tag(&mut tags, TAG_KEY, &cap.product.key); + if let Some(category) = cap.product.category.as_deref() { + if !category.trim().is_empty() { + push_tag(&mut tags, TAG_CATEGORY, category); + } + } + push_tag(&mut tags, TAG_START, &cap.start.to_string()); + push_tag(&mut tags, TAG_END, &cap.end.to_string()); + if let Some(items) = cap.tags.as_ref() { + for item in items.iter().filter(|v| !v.trim().is_empty()) { + push_tag(&mut tags, TAG_T, item); + } + } + Ok(tags) +} + +#[cfg(feature = "serde_json")] +pub fn to_wire_parts( + cap: &RadrootsResourceHarvestCap, +) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind(cap, KIND_RESOURCE_HARVEST_CAP) +} + +#[cfg(feature = "serde_json")] +pub fn to_wire_parts_with_kind( + cap: &RadrootsResourceHarvestCap, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != KIND_RESOURCE_HARVEST_CAP { + return Err(EventEncodeError::InvalidKind(kind)); + } + let tags = resource_harvest_cap_build_tags(cap)?; + let content = serde_json::to_string(cap).map_err(|_| EventEncodeError::Json)?; + Ok(WireEventParts { kind, content, tags }) +} diff --git a/events-codec/src/resource_cap/mod.rs b/events-codec/src/resource_cap/mod.rs @@ -0,0 +1,44 @@ +#![forbid(unsafe_code)] + +pub mod encode; +pub mod decode; + +#[cfg(test)] +mod tests { + use radroots_core::{RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreUnit}; + use radroots_events::resource_area::RadrootsResourceAreaRef; + use radroots_events::resource_cap::{RadrootsResourceHarvestCap, RadrootsResourceHarvestProduct}; + use crate::resource_cap::encode::resource_harvest_cap_build_tags; + + #[test] + fn resource_harvest_cap_tags_include_required_fields() { + let cap = RadrootsResourceHarvestCap { + d_tag: "cap-2025".to_string(), + resource_area: RadrootsResourceAreaRef { + pubkey: "area_pubkey".to_string(), + d_tag: "area-1".to_string(), + }, + product: RadrootsResourceHarvestProduct { + key: "nutmeg".to_string(), + category: Some("spice".to_string()), + }, + start: 1735689600, + end: 1767225600, + cap_quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(100000u32), + RadrootsCoreUnit::MassG, + ), + display_amount: None, + display_unit: None, + display_label: None, + tags: Some(vec!["community".to_string()]), + }; + + let tags = resource_harvest_cap_build_tags(&cap).expect("tags"); + assert!(tags.iter().any(|tag| tag.get(0).map(|v| v.as_str()) == Some("d"))); + assert!(tags.iter().any(|tag| tag.get(0).map(|v| v.as_str()) == Some("a"))); + assert!(tags.iter().any(|tag| tag.get(0).map(|v| v.as_str()) == Some("key"))); + assert!(tags.iter().any(|tag| tag.get(0).map(|v| v.as_str()) == Some("start"))); + assert!(tags.iter().any(|tag| tag.get(0).map(|v| v.as_str()) == Some("end"))); + } +} diff --git a/events-codec/src/tag_builders.rs b/events-codec/src/tag_builders.rs @@ -12,6 +12,8 @@ use radroots_events::{ coop::RadrootsCoop, follow::RadrootsFollow, farm::RadrootsFarm, + resource_area::RadrootsResourceArea, + resource_cap::RadrootsResourceHarvestCap, gift_wrap::RadrootsGiftWrap, job_feedback::RadrootsJobFeedback, job_request::RadrootsJobRequest, @@ -35,6 +37,8 @@ use crate::document::encode::document_build_tags; use crate::coop::encode::coop_build_tags; use crate::follow::encode::follow_build_tags; use crate::farm::encode::farm_build_tags; +use crate::resource_area::encode::resource_area_build_tags; +use crate::resource_cap::encode::resource_harvest_cap_build_tags; use crate::job::encode::JobEncodeError; use crate::job::feedback::encode::job_feedback_build_tags; use crate::job::request::encode::job_request_build_tags; @@ -118,6 +122,22 @@ impl RadrootsEventTagBuilder for RadrootsFarm { } } +impl RadrootsEventTagBuilder for RadrootsResourceArea { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + resource_area_build_tags(self) + } +} + +impl RadrootsEventTagBuilder for RadrootsResourceHarvestCap { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + resource_harvest_cap_build_tags(self) + } +} + impl RadrootsEventTagBuilder for RadrootsCoop { type Error = EventEncodeError; diff --git a/events-codec/tests/listing.rs b/events-codec/tests/listing.rs @@ -56,6 +56,8 @@ fn sample_listing(d_tag: &str) -> RadrootsListing { display_price: None, display_price_unit: None, }], + resource_area: None, + plot: None, discounts: None, inventory_available: None, availability: None, @@ -105,6 +107,8 @@ fn sample_listing_full(d_tag: &str) -> RadrootsListing { )), display_price_unit: Some(RadrootsCoreUnit::MassKg), }], + resource_area: None, + plot: None, discounts: Some(vec![RadrootsCoreDiscount { scope: RadrootsCoreDiscountScope::Bin, threshold: RadrootsCoreDiscountThreshold::BinCount { diff --git a/events/bindings/ts/src/kinds.ts b/events/bindings/ts/src/kinds.ts @@ -42,6 +42,8 @@ export const KIND_FARM = 30340; export const KIND_PLOT = 30350; export const KIND_COOP = 30360; export const KIND_DOCUMENT = 30361; +export const KIND_RESOURCE_AREA = 30370; +export const KIND_RESOURCE_HARVEST_CAP = 30371; export const KIND_APP_DATA = 30078; export const KIND_LISTING = 30402; export const KIND_APPLICATION_HANDLER = 31990; diff --git a/events/bindings/ts/src/schemas.ts b/events/bindings/ts/src/schemas.ts @@ -18,37 +18,7 @@ export const radroots_listing_location_schema = z.object({ geohash: z.string().optional() }); -export const radroots_listing_discount_schema = z.union([ - z.object({ - kind: z.literal("quantity"), - amount: z.object({ - ref_quantity: z.string(), - threshold: z.any(), - value: z.any() - }) - }), - z.object({ - kind: z.literal("mass"), - amount: z.object({ - threshold: z.any(), - value: z.any() - }) - }), - z.object({ - kind: z.literal("subtotal"), - amount: z.object({ - threshold: z.any(), - value: z.any() - }) - }), - z.object({ - kind: z.literal("total"), - amount: z.object({ - total_min: z.any(), - value: z.any() - }) - }) -]); +export const radroots_listing_discount_schema = z.any(); export const radroots_listing_price_schema = z.object({ amount: z.any(), @@ -56,9 +26,20 @@ export const radroots_listing_price_schema = z.object({ }); export const radroots_listing_quantity_schema = z.object({ - value: z.any(), - label: z.string().optional(), - count: z.number().optional() + amount: z.any(), + unit: z.any(), + label: z.string().optional() +}); + +export const radroots_listing_bin_schema = z.object({ + bin_id: z.string(), + quantity: z.any(), + price_per_canonical_unit: z.any(), + display_amount: z.number().optional(), + display_unit: z.any().optional(), + display_label: z.string().optional(), + display_price: z.any().optional(), + display_price_unit: z.any().optional() }); export const radroots_listing_product_schema = z.object({ @@ -76,9 +57,12 @@ export const radroots_listing_product_schema = z.object({ export const radroots_listing_schema = z.object({ d_tag: z.string(), product: radroots_listing_product_schema, - quantities: z.array(radroots_listing_quantity_schema), - prices: z.array(radroots_listing_price_schema), + primary_bin_id: z.string(), + bins: z.array(radroots_listing_bin_schema), discounts: z.array(radroots_listing_discount_schema).optional(), + inventory_available: z.number().optional(), + availability: z.any().optional(), + delivery_method: z.any().optional(), location: radroots_listing_location_schema.optional(), images: z.array(radroots_listing_image_schema).optional() }); diff --git a/events/bindings/ts/src/types.ts b/events/bindings/ts/src/types.ts @@ -106,7 +106,7 @@ export type RadrootsListSetEventIndex = { event: RadrootsNostrEvent, metadata: R export type RadrootsListSetEventMetadata = { id: string, author: string, published_at: number, kind: number, list_set: RadrootsListSet, }; -export type RadrootsListing = { d_tag: string, farm: RadrootsListingFarmRef, product: RadrootsListingProduct, primary_bin_id: string, bins: Array<RadrootsListingBin>, discounts?: RadrootsCoreDiscount[] | null, inventory_available?: RadrootsCoreDecimal | null, availability?: RadrootsListingAvailability | null, delivery_method?: RadrootsListingDeliveryMethod | null, location?: RadrootsListingLocation | null, images?: RadrootsListingImage[] | null, }; +export type RadrootsListing = { d_tag: string, farm: RadrootsListingFarmRef, product: RadrootsListingProduct, primary_bin_id: string, bins: Array<RadrootsListingBin>, resource_area?: RadrootsResourceAreaRef | null, plot?: RadrootsPlotRef | null, discounts?: RadrootsCoreDiscount[] | null, inventory_available?: RadrootsCoreDecimal | null, availability?: RadrootsListingAvailability | null, delivery_method?: RadrootsListingDeliveryMethod | null, location?: RadrootsListingLocation | null, images?: RadrootsListingImage[] | null, }; export type RadrootsListingAvailability = { "kind": "window", "amount": { start?: number | null, end?: number | null, } } | { "kind": "status", "amount": { status: RadrootsListingStatus, } }; @@ -162,6 +162,8 @@ export type RadrootsPlotEventMetadata = { id: string, author: string, published_ export type RadrootsPlotLocation = { primary?: string | null, city?: string | null, region?: string | null, country?: string | null, gcs: RadrootsGcsLocation, }; +export type RadrootsPlotRef = { pubkey: string, d_tag: string, }; + export type RadrootsPost = { content: string, }; export type RadrootsPostEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsPostEventMetadata, }; @@ -184,6 +186,24 @@ export type RadrootsReactionEventMetadata = { id: string, author: string, publis export type RadrootsRelayDocument = { name?: string | null, description?: string | null, pubkey?: string | null, contact?: string | null, supported_nips?: number[] | null, software?: string | null, version?: string | null, }; +export type RadrootsResourceArea = { d_tag: string, name: string, about?: string | null, location: RadrootsResourceAreaLocation, tags?: string[] | null, }; + +export type RadrootsResourceAreaEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsResourceAreaEventMetadata, }; + +export type RadrootsResourceAreaEventMetadata = { id: string, author: string, published_at: number, kind: number, area: RadrootsResourceArea, }; + +export type RadrootsResourceAreaLocation = { primary?: string | null, city?: string | null, region?: string | null, country?: string | null, gcs: RadrootsGcsLocation, }; + +export type RadrootsResourceAreaRef = { pubkey: string, d_tag: string, }; + +export type RadrootsResourceHarvestCap = { d_tag: string, resource_area: RadrootsResourceAreaRef, product: RadrootsResourceHarvestProduct, start: bigint, end: bigint, cap_quantity: RadrootsCoreQuantity, display_amount?: RadrootsCoreDecimal | null, display_unit?: RadrootsCoreUnit | null, display_label?: string | null, tags?: string[] | null, }; + +export type RadrootsResourceHarvestCapEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsResourceHarvestCapEventMetadata, }; + +export type RadrootsResourceHarvestCapEventMetadata = { id: string, author: string, published_at: number, kind: number, cap: RadrootsResourceHarvestCap, }; + +export type RadrootsResourceHarvestProduct = { key: string, category?: string | null, }; + export type RadrootsSeal = { content: string, }; export type RadrootsSealEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsSealEventMetadata, }; diff --git a/events/src/kinds.rs b/events/src/kinds.rs @@ -42,6 +42,8 @@ pub const KIND_FARM: u32 = 30340; pub const KIND_PLOT: u32 = 30350; pub const KIND_COOP: u32 = 30360; pub const KIND_DOCUMENT: u32 = 30361; +pub const KIND_RESOURCE_AREA: u32 = 30370; +pub const KIND_RESOURCE_HARVEST_CAP: u32 = 30371; pub const KIND_APP_DATA: u32 = 30078; pub const KIND_LISTING: u32 = 30402; pub const KIND_APPLICATION_HANDLER: u32 = 31990; @@ -171,6 +173,8 @@ mod kinds_constants_tests { ("KIND_PLOT", KIND_PLOT), ("KIND_COOP", KIND_COOP), ("KIND_DOCUMENT", KIND_DOCUMENT), + ("KIND_RESOURCE_AREA", KIND_RESOURCE_AREA), + ("KIND_RESOURCE_HARVEST_CAP", KIND_RESOURCE_HARVEST_CAP), ("KIND_APP_DATA", KIND_APP_DATA), ("KIND_LISTING", KIND_LISTING), ("KIND_APPLICATION_HANDLER", KIND_APPLICATION_HANDLER), diff --git a/events/src/lib.rs b/events/src/lib.rs @@ -24,6 +24,8 @@ pub mod list_set; pub mod app_data; pub mod coop; pub mod farm; +pub mod resource_area; +pub mod resource_cap; pub mod message; pub mod plot; pub mod message_file; diff --git a/events/src/listing.rs b/events/src/listing.rs @@ -6,6 +6,8 @@ use radroots_core::{ use ts_rs::TS; use crate::RadrootsNostrEvent; +use crate::plot::RadrootsPlotRef; +use crate::resource_area::RadrootsResourceAreaRef; #[cfg(not(feature = "std"))] use alloc::{string::String, vec::Vec}; @@ -88,6 +90,16 @@ pub struct RadrootsListing { pub bins: Vec<RadrootsListingBin>, #[cfg_attr( feature = "ts-rs", + ts(optional, type = "RadrootsResourceAreaRef | null") + )] + pub resource_area: Option<RadrootsResourceAreaRef>, + #[cfg_attr( + feature = "ts-rs", + ts(optional, type = "RadrootsPlotRef | null") + )] + pub plot: Option<RadrootsPlotRef>, + #[cfg_attr( + feature = "ts-rs", ts(optional, type = "RadrootsCoreDiscount[] | null") )] pub discounts: Option<Vec<RadrootsCoreDiscount>>, diff --git a/events/src/plot.rs b/events/src/plot.rs @@ -30,6 +30,15 @@ pub struct RadrootsPlotEventMetadata { #[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 RadrootsPlotRef { + pub pubkey: String, + pub d_tag: 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 RadrootsPlot { pub d_tag: String, pub farm: RadrootsFarmRef, diff --git a/events/src/resource_area.rs b/events/src/resource_area.rs @@ -0,0 +1,69 @@ +#![forbid(unsafe_code)] + +use crate::RadrootsNostrEvent; +use crate::farm::RadrootsGcsLocation; +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +#[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 RadrootsResourceAreaEventIndex { + pub event: RadrootsNostrEvent, + pub metadata: RadrootsResourceAreaEventMetadata, +} + +#[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 RadrootsResourceAreaEventMetadata { + pub id: String, + pub author: String, + pub published_at: u32, + pub kind: u32, + pub area: RadrootsResourceArea, +} + +#[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 RadrootsResourceArea { + pub d_tag: String, + pub name: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub about: Option<String>, + pub location: RadrootsResourceAreaLocation, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string[] | null"))] + pub tags: Option<Vec<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 RadrootsResourceAreaRef { + pub pubkey: String, + pub d_tag: 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 RadrootsResourceAreaLocation { + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub primary: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub city: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub region: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub country: Option<String>, + pub gcs: RadrootsGcsLocation, +} diff --git a/events/src/resource_cap.rs b/events/src/resource_cap.rs @@ -0,0 +1,64 @@ +#![forbid(unsafe_code)] + +use radroots_core::{RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreUnit}; + +use crate::RadrootsNostrEvent; +use crate::resource_area::RadrootsResourceAreaRef; +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +#[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 RadrootsResourceHarvestCapEventIndex { + pub event: RadrootsNostrEvent, + pub metadata: RadrootsResourceHarvestCapEventMetadata, +} + +#[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 RadrootsResourceHarvestCapEventMetadata { + pub id: String, + pub author: String, + pub published_at: u32, + pub kind: u32, + pub cap: RadrootsResourceHarvestCap, +} + +#[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 RadrootsResourceHarvestProduct { + pub key: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub category: 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)] +pub struct RadrootsResourceHarvestCap { + pub d_tag: String, + pub resource_area: RadrootsResourceAreaRef, + pub product: RadrootsResourceHarvestProduct, + pub start: u64, + pub end: u64, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreQuantity"))] + pub cap_quantity: RadrootsCoreQuantity, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "RadrootsCoreDecimal | null"))] + pub display_amount: Option<RadrootsCoreDecimal>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "RadrootsCoreUnit | null"))] + pub display_unit: Option<RadrootsCoreUnit>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub display_label: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string[] | null"))] + pub tags: Option<Vec<String>>, +} diff --git a/net-core/src/nostr_client/manager.rs b/net-core/src/nostr_client/manager.rs @@ -43,7 +43,7 @@ impl NostrClientManager { async move { use futures::StreamExt; - let mut since = since_unix.unwrap_or_else(|| RadrootsNostrTimestamp::now().as_u64()); + let mut since = since_unix.unwrap_or_else(|| RadrootsNostrTimestamp::now().as_secs()); loop { let filter = radroots_nostr_post_events_filter(None, Some(since)); @@ -59,9 +59,13 @@ impl NostrClientManager { } }; - while let Some(event) = stream.next().await { + while let Some((_, event)) = stream.next().await { + let event = match event { + Ok(event) => event, + Err(_) => continue, + }; let meta = radroots_nostr::event_adapters::to_post_event_metadata(&event); - let ts = event.created_at.as_u64(); + let ts = event.created_at.as_secs(); since = ts.saturating_add(1); let _ = inner.post_events_tx.send(meta); } diff --git a/trade/bindings/ts/package.json b/trade/bindings/ts/package.json @@ -23,7 +23,7 @@ "build:cjs": "tsc -p tsconfig.cjs.json", "build": "npm run build:esm && npm run build:cjs", "prebuild": "npm run clean && npm run prepend-imports", - "prepend-imports": "bash -c 'f=./src/types.ts; line=\"import type { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit } from \\\"@radroots/core-bindings\\\";\"; grep -qxF \"$line\" \"$f\" || (echo -e \"$line\\n\\n$(cat $f)\" > \"$f\")' && bash -c 'f=./src/types.ts; old=\"import type { RadrootsListingDiscount, RadrootsListingImage, RadrootsNostrEventPtr } from \\\"@radroots/events-bindings\\\";\"; if grep -qxF \"$old\" \"$f\"; then grep -vxF \"$old\" \"$f\" > \"$f.tmp\" && mv \"$f.tmp\" \"$f\"; fi; line=\"import type { RadrootsListingImage, RadrootsNostrEventPtr } from \\\"@radroots/events-bindings\\\";\"; grep -qxF \"$line\" \"$f\" || (echo -e \"$line\\n\\n$(cat $f)\" > \"$f\")'", + "prepend-imports": "bash -c 'f=./src/types.ts; line=\"import type { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit } from \\\"@radroots/core-bindings\\\";\"; grep -qxF \"$line\" \"$f\" || (echo -e \"$line\\n\\n$(cat $f)\" > \"$f\")' && bash -c 'f=./src/types.ts; old=\"import type { RadrootsListingDiscount, RadrootsListingImage, RadrootsNostrEventPtr } from \\\"@radroots/events-bindings\\\";\"; if grep -qxF \"$old\" \"$f\"; then grep -vxF \"$old\" \"$f\" > \"$f.tmp\" && mv \"$f.tmp\" \"$f\"; fi; old2=\"import type { RadrootsListingImage, RadrootsNostrEventPtr } from \\\"@radroots/events-bindings\\\";\"; if grep -qxF \"$old2\" \"$f\"; then grep -vxF \"$old2\" \"$f\" > \"$f.tmp\" && mv \"$f.tmp\" \"$f\"; fi; line=\"import type { RadrootsListingImage, RadrootsNostrEventPtr, RadrootsPlotRef, RadrootsResourceAreaRef } from \\\"@radroots/events-bindings\\\";\"; grep -qxF \"$line\" \"$f\" || (echo -e \"$line\\n\\n$(cat $f)\" > \"$f\")'", "clean": "rimraf dist", "dev": "npm run watch", "watch": "tsc -w" diff --git a/trade/bindings/ts/src/types.ts b/trade/bindings/ts/src/types.ts @@ -1,10 +1,10 @@ -import type { RadrootsListingImage, RadrootsNostrEventPtr } from "@radroots/events-bindings"; +import type { RadrootsListingImage, RadrootsNostrEventPtr, RadrootsPlotRef, RadrootsResourceAreaRef } from "@radroots/events-bindings"; import type { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit } from "@radroots/core-bindings"; // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type RadrootsListing = { d_tag: string, farm: RadrootsListingFarmRef, product: RadrootsListingProduct, primary_bin_id: string, bins: Array<RadrootsListingBin>, discounts?: RadrootsCoreDiscount[] | null, inventory_available?: RadrootsCoreDecimal | null, availability?: RadrootsListingAvailability | null, delivery_method?: RadrootsListingDeliveryMethod | null, location?: RadrootsListingLocation | null, images?: RadrootsListingImage[] | null, }; +export type RadrootsListing = { d_tag: string, farm: RadrootsListingFarmRef, product: RadrootsListingProduct, primary_bin_id: string, bins: Array<RadrootsListingBin>, resource_area?: RadrootsResourceAreaRef | null, plot?: RadrootsPlotRef | null, discounts?: RadrootsCoreDiscount[] | null, inventory_available?: RadrootsCoreDecimal | null, availability?: RadrootsListingAvailability | null, delivery_method?: RadrootsListingDeliveryMethod | null, location?: RadrootsListingLocation | null, images?: RadrootsListingImage[] | null, }; export type RadrootsListingAvailability = { "kind": "window", "amount": { start?: number | null, end?: number | null, } } | { "kind": "status", "amount": { status: RadrootsListingStatus, } }; diff --git a/trade/src/listing/codec.rs b/trade/src/listing/codec.rs @@ -13,7 +13,9 @@ use radroots_events::listing::{ RadrootsListingImageSize, RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus, }; -use radroots_events::kinds::KIND_FARM; +use radroots_events::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA}; +use radroots_events::plot::RadrootsPlotRef; +use radroots_events::resource_area::RadrootsResourceAreaRef; use radroots_events::tags::TAG_D; use radroots_events_codec::error::EventEncodeError; use radroots_events_codec::listing::tags::{listing_tags_with_options, ListingTagOptions}; @@ -25,6 +27,8 @@ 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"; @@ -107,6 +111,8 @@ pub fn listing_from_event_parts( 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)?; if !content.trim().is_empty() { #[cfg(feature = "serde_json")] @@ -127,12 +133,36 @@ pub fn listing_from_event_parts( if listing.farm.pubkey != farm_pubkey { return Err(TradeListingParseError::InvalidTag(TAG_P.to_string())); } + 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(TradeListingParseError::InvalidTag( + TAG_RADROOTS_RESOURCE_AREA.to_string(), + )); + } + } + } + } + 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(TradeListingParseError::InvalidTag( + TAG_RADROOTS_PLOT.to_string(), + )); + } + } + } + } return Ok(listing); } } } - listing_from_tags(tags, d_tag, farm_ref, farm_pubkey) + listing_from_tags(tags, d_tag, farm_ref, farm_pubkey, resource_area, plot) } pub fn listing_tags_build(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, TradeListingParseError> { @@ -155,6 +185,8 @@ fn listing_from_tags( d_tag: String, farm_ref: RadrootsListingFarmRef, farm_pubkey: String, + resource_area: Option<RadrootsResourceAreaRef>, + plot: Option<RadrootsPlotRef>, ) -> Result<RadrootsListing, TradeListingParseError> { let mut product = RadrootsListingProduct { key: String::new(), @@ -450,6 +482,8 @@ fn listing_from_tags( product, primary_bin_id, bins, + resource_area, + plot, discounts: if discounts.is_empty() { None } else { Some(discounts) }, inventory_available, availability, @@ -460,49 +494,120 @@ fn listing_from_tags( } fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsListingFarmRef, TradeListingParseError> { + for tag in tags.iter().filter(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_A)) { + let value = tag + .get(1) + .map(|s| s.to_string()) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_A.to_string()))?; + let mut parts = value.splitn(3, ':'); + let kind = parts + .next() + .and_then(|v| v.parse::<u32>().ok()) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_A.to_string()))?; + if kind != KIND_FARM { + continue; + } + let pubkey = parts + .next() + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_A.to_string()))? + .to_string(); + let d_tag = parts + .next() + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_A.to_string()))? + .to_string(); + if pubkey.trim().is_empty() || d_tag.trim().is_empty() { + return Err(TradeListingParseError::InvalidTag(TAG_A.to_string())); + } + return Ok(RadrootsListingFarmRef { pubkey, d_tag }); + } + Err(TradeListingParseError::MissingTag(TAG_A.to_string())) +} + +fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, TradeListingParseError> { let tag = tags .iter() - .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_A)) - .ok_or_else(|| TradeListingParseError::MissingTag(TAG_A.to_string()))?; + .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_P)) + .ok_or_else(|| TradeListingParseError::MissingTag(TAG_P.to_string()))?; let value = tag .get(1) .map(|s| s.to_string()) - .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_A.to_string()))?; + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_P.to_string()))?; + if value.trim().is_empty() { + return Err(TradeListingParseError::InvalidTag(TAG_P.to_string())); + } + Ok(value) +} + +fn parse_resource_area( + tags: &[Vec<String>], +) -> Result<Option<RadrootsResourceAreaRef>, TradeListingParseError> { + let tag = tags + .iter() + .find(|t| t.get(0).map(|s| s.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()) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))?; let mut parts = value.splitn(3, ':'); let kind = parts .next() .and_then(|v| v.parse::<u32>().ok()) - .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_A.to_string()))?; - if kind != KIND_FARM { - return Err(TradeListingParseError::InvalidTag(TAG_A.to_string())); + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))?; + if kind != KIND_RESOURCE_AREA { + return Err(TradeListingParseError::InvalidTag( + TAG_RADROOTS_RESOURCE_AREA.to_string(), + )); } let pubkey = parts .next() - .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_A.to_string()))? + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))? .to_string(); let d_tag = parts .next() - .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_A.to_string()))? + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))? .to_string(); if pubkey.trim().is_empty() || d_tag.trim().is_empty() { - return Err(TradeListingParseError::InvalidTag(TAG_A.to_string())); + return Err(TradeListingParseError::InvalidTag( + TAG_RADROOTS_RESOURCE_AREA.to_string(), + )); } - Ok(RadrootsListingFarmRef { pubkey, d_tag }) + Ok(Some(RadrootsResourceAreaRef { pubkey, d_tag })) } -fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, TradeListingParseError> { +fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, TradeListingParseError> { let tag = tags .iter() - .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_P)) - .ok_or_else(|| TradeListingParseError::MissingTag(TAG_P.to_string()))?; + .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_RADROOTS_PLOT)); + let Some(tag) = tag else { + return Ok(None); + }; let value = tag .get(1) .map(|s| s.to_string()) - .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_P.to_string()))?; - if value.trim().is_empty() { - return Err(TradeListingParseError::InvalidTag(TAG_P.to_string())); + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))?; + let mut parts = value.splitn(3, ':'); + let kind = parts + .next() + .and_then(|v| v.parse::<u32>().ok()) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))?; + if kind != KIND_PLOT { + return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string())); } - Ok(value) + let pubkey = parts + .next() + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))? + .to_string(); + let d_tag = parts + .next() + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))? + .to_string(); + if pubkey.trim().is_empty() || d_tag.trim().is_empty() { + return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string())); + } + Ok(Some(RadrootsPlotRef { pubkey, d_tag })) } #[cfg(test)] @@ -551,6 +656,8 @@ mod tests { "listing-1".to_string(), farm_ref(), "seller".to_string(), + None, + None, ) .expect("listing"); diff --git a/trade/src/listing/validation.rs b/trade/src/listing/validation.rs @@ -319,6 +319,8 @@ mod tests { display_price: None, display_price_unit: None, }], + resource_area: None, + plot: None, discounts: None, inventory_available: Some(RadrootsCoreDecimal::from(5u32)), availability: Some(RadrootsListingAvailability::Status {