lib

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

commit 33b566cecf71ce0e6f2a122c52779b272797e920
parent b3c2bafbb8b209954d613dc03dd3700049e41243
Author: triesap <tyson@radroots.org>
Date:   Wed, 31 Dec 2025 12:17:46 +0000

listing: add farm references and validate seller identity


- Gate KIND_DOCUMENT import behind serde_json feature
- Encode listing farm pubkey/address tags (p/a) from farm ref
- Decode/parse farm ref tags and enforce consistency with content
- Add farm listings list-set helpers and seller-mismatch validation

Diffstat:
Mevents-codec/src/document/encode.rs | 4+++-
Mevents-codec/src/farm/list_sets.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mevents-codec/src/farm/mod.rs | 42++++++++++++++++++++++++++++++++++++++++++
Mevents-codec/src/listing/decode.rs | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mevents-codec/src/listing/tags.rs | 27++++++++++++++++++++++++++-
Mevents-codec/tests/listing.rs | 40++++++++++++++++++++++++++++++++++------
Mevents/bindings/ts/src/types.ts | 4+++-
Mevents/src/listing.rs | 20++++++++++++++++++++
Mtrade/bindings/ts/src/types.ts | 6++++--
Mtrade/src/listing/codec.rs | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtrade/src/listing/validation.rs | 42++++++++++++++++++++++++++++++++++++++++--
11 files changed, 358 insertions(+), 17 deletions(-)

diff --git a/events-codec/src/document/encode.rs b/events-codec/src/document/encode.rs @@ -5,13 +5,15 @@ use alloc::{string::{String, ToString}, vec::Vec}; use radroots_events::{ document::RadrootsDocument, - kinds::KIND_DOCUMENT, tags::TAG_D, }; use crate::error::EventEncodeError; #[cfg(feature = "serde_json")] +use radroots_events::kinds::KIND_DOCUMENT; + +#[cfg(feature = "serde_json")] use crate::wire::WireEventParts; const TAG_T: &str = "t"; diff --git a/events-codec/src/farm/list_sets.rs b/events-codec/src/farm/list_sets.rs @@ -6,6 +6,8 @@ use alloc::{format, string::{String, ToString}, vec, vec::Vec}; use radroots_events::list::RadrootsListEntry; use radroots_events::list_set::RadrootsListSet; use radroots_events::plot::RadrootsPlot; +use radroots_events::listing::RadrootsListing; +use radroots_events::kinds::KIND_LISTING; use crate::error::EventEncodeError; use crate::plot::encode::plot_address; @@ -124,6 +126,57 @@ where }) } +pub fn farm_listings_list_set<I, S>( + farm_id: &str, + farm_pubkey: &str, + listing_ids: I, +) -> Result<RadrootsListSet, EventEncodeError> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let mut entries = Vec::new(); + for listing_id in listing_ids { + let listing_id = listing_id.as_ref().trim(); + if listing_id.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("listing_id")); + } + let mut address = String::new(); + address.push_str(&KIND_LISTING.to_string()); + address.push(':'); + address.push_str(farm_pubkey); + address.push(':'); + address.push_str(listing_id); + entries.push(RadrootsListEntry { + tag: "a".to_string(), + values: vec![address], + }); + } + Ok(RadrootsListSet { + d_tag: farm_list_set_id(farm_id, "listings")?, + content: String::new(), + entries, + title: None, + description: None, + image: None, + }) +} + +pub fn farm_listings_list_set_from_listings<'a, I>( + farm_id: &str, + farm_pubkey: &str, + listings: I, +) -> Result<RadrootsListSet, EventEncodeError> +where + I: IntoIterator<Item = &'a RadrootsListing>, +{ + farm_listings_list_set( + farm_id, + farm_pubkey, + listings.into_iter().map(|listing| listing.d_tag.as_str()), + ) +} + pub fn farm_plots_list_set_from_plots<'a, I>( farm_id: &str, farm_pubkey: &str, diff --git a/events-codec/src/farm/mod.rs b/events-codec/src/farm/mod.rs @@ -16,9 +16,11 @@ mod tests { use crate::farm::encode::{farm_build_tags, farm_ref_tags}; use crate::farm::list_sets::{ farm_members_list_set, + farm_listings_list_set_from_listings, farm_plots_list_set_from_plots, member_of_farms_list_set, }; + use radroots_events::listing::{RadrootsListing, RadrootsListingFarmRef, RadrootsListingProduct}; #[test] fn farm_tags_include_required_fields() { @@ -127,4 +129,44 @@ mod tests { "30350:farm_pubkey:plot-1" ); } + + #[test] + fn farm_listings_list_set_uses_listing_addresses() { + let listings = vec![RadrootsListing { + d_tag: "listing-1".to_string(), + farm: RadrootsListingFarmRef { + pubkey: "farm_pubkey".to_string(), + d_tag: "farm-1".to_string(), + }, + product: RadrootsListingProduct { + key: "coffee".to_string(), + title: "Coffee".to_string(), + category: "coffee".to_string(), + summary: None, + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + quantities: vec![], + prices: vec![], + discounts: None, + inventory_available: None, + availability: None, + delivery_method: None, + location: None, + images: None, + }]; + + let listings_list = farm_listings_list_set_from_listings("farm-1", "farm_pubkey", &listings) + .expect("listings list"); + assert_eq!(listings_list.d_tag, "farm:farm-1:listings"); + assert_eq!(listings_list.entries.len(), 1); + assert_eq!(listings_list.entries[0].tag, "a"); + assert_eq!( + listings_list.entries[0].values[0], + "30402:farm_pubkey:listing-1" + ); + } } diff --git a/events-codec/src/listing/decode.rs b/events-codec/src/listing/decode.rs @@ -5,14 +5,17 @@ use alloc::{string::{String, ToString}, vec::Vec}; use radroots_events::{ RadrootsNostrEvent, + kinds::KIND_FARM, kinds::KIND_LISTING, - listing::{RadrootsListing, RadrootsListingEventIndex, RadrootsListingEventMetadata}, + listing::{RadrootsListing, RadrootsListingEventIndex, RadrootsListingEventMetadata, RadrootsListingFarmRef}, tags::TAG_D, }; use crate::error::EventParseError; const DEFAULT_KIND: u32 = KIND_LISTING; +const TAG_A: &str = "a"; +const TAG_P: &str = "p"; fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { let tag = tags @@ -29,6 +32,52 @@ fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { Ok(value) } +fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsListingFarmRef, EventParseError> { + let tag = tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_A)) + .ok_or(EventParseError::MissingTag(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 { + return Err(EventParseError::InvalidTag(TAG_A)); + } + 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)); + } + Ok(RadrootsListingFarmRef { pubkey, d_tag }) +} + +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) +} + pub fn listing_from_event( kind: u32, tags: &[Vec<String>], @@ -44,6 +93,8 @@ pub fn listing_from_event( return Err(EventParseError::InvalidJson("content")); } let d_tag = parse_d_tag(tags)?; + let farm_ref = parse_farm_ref(tags)?; + let farm_pubkey = parse_farm_pubkey(tags)?; let mut listing: RadrootsListing = serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; @@ -53,6 +104,15 @@ pub fn listing_from_event( return Err(EventParseError::InvalidTag(TAG_D)); } + 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)); + } + Ok(listing) } diff --git a/events-codec/src/listing/tags.rs b/events-codec/src/listing/tags.rs @@ -11,10 +11,11 @@ use radroots_core::{ RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCorePercent, RadrootsCoreQuantity, }; use radroots_events::listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, + RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingDiscount, RadrootsListingImage, RadrootsListingLocation, RadrootsListingQuantity, RadrootsListingStatus, }; +use radroots_events::kinds::KIND_FARM; use radroots_events::tags::TAG_D; use crate::error::EventEncodeError; @@ -35,6 +36,8 @@ const TAG_DELIVERY: &str = "delivery"; const TAG_PUBLISHED_AT: &str = "published_at"; const TAG_STATUS: &str = "status"; const TAG_EXPIRES_AT: &str = "expires_at"; +const TAG_P: &str = "p"; +const TAG_A: &str = "a"; const GEOHASH_PRECISION_DEFAULT: usize = 9; const DD_MAX_RESOLUTION_DEFAULT: u32 = 9; @@ -96,6 +99,7 @@ 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)?; let product = &listing.product; push_tag_value(&mut tags, "key", &product.key); @@ -208,6 +212,27 @@ pub fn listing_tags_with_options( Ok(tags) } +fn push_farm_tags( + tags: &mut Vec<Vec<String>>, + farm: &RadrootsListingFarmRef, +) -> Result<(), 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 address = String::new(); + address.push_str(&KIND_FARM.to_string()); + address.push(':'); + address.push_str(&farm.pubkey); + address.push(':'); + address.push_str(&farm.d_tag); + tags.push(vec![TAG_P.to_string(), farm.pubkey.clone()]); + tags.push(vec![TAG_A.to_string(), address]); + Ok(()) +} + fn tag_listing_quantity(quantity: &RadrootsListingQuantity) -> Vec<String> { let mut tag = Vec::with_capacity(5); tag.push(TAG_QUANTITY.to_string()); diff --git a/events-codec/tests/listing.rs b/events-codec/tests/listing.rs @@ -8,9 +8,9 @@ use radroots_events::{ kinds::{KIND_LISTING, KIND_POST}, listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, - RadrootsListingDiscount, RadrootsListingImage, RadrootsListingImageSize, - RadrootsListingLocation, RadrootsListingProduct, RadrootsListingQuantity, - RadrootsListingStatus, + RadrootsListingDiscount, RadrootsListingFarmRef, RadrootsListingImage, + RadrootsListingImageSize, RadrootsListingLocation, RadrootsListingProduct, + RadrootsListingQuantity, RadrootsListingStatus, }, }; use radroots_events::tags::TAG_D; @@ -29,6 +29,10 @@ fn sample_listing(d_tag: &str) -> RadrootsListing { RadrootsListing { d_tag: d_tag.to_string(), + farm: RadrootsListingFarmRef { + pubkey: "farm_pubkey".to_string(), + d_tag: "farm-1".to_string(), + }, product: RadrootsListingProduct { key: "sku".to_string(), title: "Widget".to_string(), @@ -67,6 +71,10 @@ fn sample_listing_full(d_tag: &str) -> RadrootsListing { RadrootsListing { d_tag: d_tag.to_string(), + farm: RadrootsListingFarmRef { + pubkey: "farm_pubkey".to_string(), + d_tag: "farm-1".to_string(), + }, product: RadrootsListingProduct { key: "sku".to_string(), title: "Widget".to_string(), @@ -138,7 +146,11 @@ fn listing_roundtrip_from_event() { 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(), "filled".to_string()]]; + let tags = vec![ + vec![TAG_D.to_string(), "filled".to_string()], + vec!["p".to_string(), "farm_pubkey".to_string()], + vec!["a".to_string(), "30340:farm_pubkey:farm-1".to_string()], + ]; let decoded = listing_from_event(KIND_LISTING, &tags, &content).unwrap(); assert_eq!(decoded.d_tag, "filled"); @@ -148,7 +160,11 @@ fn listing_from_event_fills_missing_d_tag() { fn listing_from_event_rejects_mismatched_d_tag() { let listing = sample_listing("a"); let content = serde_json::to_string(&listing).unwrap(); - let tags = vec![vec![TAG_D.to_string(), "b".to_string()]]; + let tags = vec![ + vec![TAG_D.to_string(), "b".to_string()], + vec!["p".to_string(), "farm_pubkey".to_string()], + vec!["a".to_string(), "30340:farm_pubkey:farm-1".to_string()], + ]; let err = listing_from_event(KIND_LISTING, &tags, &content).unwrap_err(); assert!(matches!(err, EventParseError::InvalidTag(TAG_D))); @@ -158,7 +174,11 @@ fn listing_from_event_rejects_mismatched_d_tag() { fn listing_from_event_rejects_wrong_kind() { let listing = sample_listing("listing-1"); let content = serde_json::to_string(&listing).unwrap(); - let tags = vec![vec![TAG_D.to_string(), "listing-1".to_string()]]; + let tags = vec![ + vec![TAG_D.to_string(), "listing-1".to_string()], + vec!["p".to_string(), "farm_pubkey".to_string()], + vec!["a".to_string(), "30340:farm_pubkey:farm-1".to_string()], + ]; let err = listing_from_event(KIND_POST, &tags, &content).unwrap_err(); assert!(matches!( @@ -180,6 +200,14 @@ fn listing_build_tags_includes_listing_fields() { && t.get(1).map(|s| s.as_str()) == Some("listing-1") })); assert!(tags.iter().any(|t| { + t.get(0).map(|s| s.as_str()) == Some("p") + && t.get(1).map(|s| s.as_str()) == Some("farm_pubkey") + })); + assert!(tags.iter().any(|t| { + t.get(0).map(|s| s.as_str()) == Some("a") + && t.get(1).map(|s| s.as_str()) == Some("30340:farm_pubkey:farm-1") + })); + assert!(tags.iter().any(|t| { t.get(0).map(|s| s.as_str()) == Some("key") && t.get(1).map(|s| s.as_str()) == Some("sku") })); 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, product: RadrootsListingProduct, quantities: Array<RadrootsListingQuantity>, prices: RadrootsCoreQuantityPrice[], discounts?: RadrootsListingDiscount[] | 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, quantities: Array<RadrootsListingQuantity>, prices: RadrootsCoreQuantityPrice[], discounts?: RadrootsListingDiscount[] | 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, } }; @@ -118,6 +118,8 @@ export type RadrootsListingEventIndex = { event: RadrootsNostrEvent, metadata: R export type RadrootsListingEventMetadata = { id: string, author: string, published_at: number, kind: number, listing: RadrootsListing, }; +export type RadrootsListingFarmRef = { pubkey: string, d_tag: string, }; + export type RadrootsListingImage = { url: string, size?: RadrootsListingImageSize | null, }; export type RadrootsListingImageSize = { w: number, h: number, }; diff --git a/events/src/listing.rs b/events/src/listing.rs @@ -81,6 +81,8 @@ pub enum RadrootsListingDeliveryMethod { #[derive(Clone, Debug)] pub struct RadrootsListing { pub d_tag: String, + #[cfg_attr(feature = "serde", serde(default))] + pub farm: RadrootsListingFarmRef, pub product: RadrootsListingProduct, pub quantities: Vec<RadrootsListingQuantity>, #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreQuantityPrice[]"))] @@ -121,6 +123,24 @@ pub struct RadrootsListing { #[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 RadrootsListingFarmRef { + pub pubkey: String, + pub d_tag: String, +} + +impl Default for RadrootsListingFarmRef { + fn default() -> Self { + Self { + pubkey: String::new(), + d_tag: String::new(), + } + } +} + +#[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 RadrootsListingProduct { pub key: String, pub title: String, diff --git a/trade/bindings/ts/src/types.ts b/trade/bindings/ts/src/types.ts @@ -4,12 +4,14 @@ import type { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscountVal // 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, product: RadrootsListingProduct, quantities: Array<RadrootsListingQuantity>, prices: RadrootsCoreQuantityPrice[], discounts?: RadrootsListingDiscount[] | 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, quantities: Array<RadrootsListingQuantity>, prices: RadrootsCoreQuantityPrice[], discounts?: RadrootsListingDiscount[] | 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, } }; export type RadrootsListingDeliveryMethod = { "kind": "pickup" } | { "kind": "local_delivery" } | { "kind": "shipping" } | { "kind": "other", "amount": { method: string, } }; +export type RadrootsListingFarmRef = { pubkey: string, d_tag: string, }; + export type RadrootsListingLocation = { primary: string, city?: string | null, region?: string | null, country?: string | null, lat?: number | null, lng?: number | null, geohash?: string | null, }; export type RadrootsListingProduct = { key: string, title: string, category: string, summary?: string | null, process?: string | null, lot?: string | null, location?: string | null, profile?: string | null, year?: string | null, }; @@ -90,7 +92,7 @@ export type TradeListingValidateRequest = { listing_event?: RadrootsNostrEventPt export type TradeListingValidateResult = { valid: boolean, errors: TradeListingValidationError[], }; -export type TradeListingValidationError = { "kind": "invalid_kind", "amount": { kind: number, } } | { "kind": "missing_listing_id" } | { "kind": "listing_event_not_found", "amount": { listing_addr: string, } } | { "kind": "listing_event_fetch_failed", "amount": { listing_addr: string, } } | { "kind": "parse_error", "amount": { error: TradeListingParseError, } } | { "kind": "missing_title" } | { "kind": "missing_description" } | { "kind": "missing_product_type" } | { "kind": "missing_price" } | { "kind": "invalid_price" } | { "kind": "missing_inventory" } | { "kind": "invalid_inventory" } | { "kind": "missing_availability" } | { "kind": "missing_location" } | { "kind": "missing_delivery_method" }; +export type TradeListingValidationError = { "kind": "invalid_kind", "amount": { kind: number, } } | { "kind": "missing_listing_id" } | { "kind": "listing_event_not_found", "amount": { listing_addr: string, } } | { "kind": "listing_event_fetch_failed", "amount": { listing_addr: string, } } | { "kind": "parse_error", "amount": { error: TradeListingParseError, } } | { "kind": "invalid_seller" } | { "kind": "missing_farm_profile" } | { "kind": "missing_farm_record" } | { "kind": "missing_title" } | { "kind": "missing_description" } | { "kind": "missing_product_type" } | { "kind": "missing_price" } | { "kind": "invalid_price" } | { "kind": "missing_inventory" } | { "kind": "invalid_inventory" } | { "kind": "missing_availability" } | { "kind": "missing_location" } | { "kind": "missing_delivery_method" }; export type TradeOrder = { order_id: string, listing_addr: string, buyer_pubkey: string, seller_pubkey: string, items: Array<TradeOrderItem>, discounts?: RadrootsCoreDiscountValue[] | null, notes?: string | null, status: TradeOrderStatus, }; diff --git a/trade/src/listing/codec.rs b/trade/src/listing/codec.rs @@ -6,9 +6,11 @@ use alloc::{string::String, vec::Vec}; use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit}; use radroots_events::listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, - RadrootsListingDiscount, RadrootsListingImage, RadrootsListingImageSize, RadrootsListingLocation, - RadrootsListingProduct, RadrootsListingQuantity, RadrootsListingStatus, + RadrootsListingDiscount, RadrootsListingFarmRef, RadrootsListingImage, + RadrootsListingImageSize, RadrootsListingLocation, RadrootsListingProduct, + RadrootsListingQuantity, RadrootsListingStatus, }; +use radroots_events::kinds::KIND_FARM; use radroots_events::tags::TAG_D; use radroots_events_codec::error::EventEncodeError; use radroots_events_codec::listing::tags::{listing_tags_with_options, ListingTagOptions}; @@ -26,6 +28,8 @@ const TAG_DELIVERY: &str = "delivery"; const TAG_PUBLISHED_AT: &str = "published_at"; const TAG_STATUS: &str = "status"; const TAG_EXPIRES_AT: &str = "expires_at"; +const TAG_P: &str = "p"; +const TAG_A: &str = "a"; #[cfg_attr(feature = "ts-rs", derive(TS))] #[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] @@ -96,6 +100,8 @@ pub fn listing_from_event_parts( content: &str, ) -> Result<RadrootsListing, TradeListingParseError> { let d_tag = parse_d_tag(tags)?; + let farm_ref = parse_farm_ref(tags)?; + let farm_pubkey = parse_farm_pubkey(tags)?; if !content.trim().is_empty() { #[cfg(feature = "serde_json")] @@ -106,12 +112,22 @@ pub fn listing_from_event_parts( } else if listing.d_tag != d_tag { return Err(TradeListingParseError::InvalidTag(TAG_D.to_string())); } + 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(TradeListingParseError::InvalidTag(TAG_A.to_string())); + } + if listing.farm.pubkey != farm_pubkey { + return Err(TradeListingParseError::InvalidTag(TAG_P.to_string())); + } return Ok(listing); } } } - listing_from_tags(tags, d_tag) + listing_from_tags(tags, d_tag, farm_ref, farm_pubkey) } pub fn listing_tags_build(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, TradeListingParseError> { @@ -132,6 +148,8 @@ fn map_listing_tags_error(err: EventEncodeError) -> TradeListingParseError { fn listing_from_tags( tags: &[Vec<String>], d_tag: String, + farm_ref: RadrootsListingFarmRef, + farm_pubkey: String, ) -> Result<RadrootsListing, TradeListingParseError> { let mut product = RadrootsListingProduct { key: String::new(), @@ -326,8 +344,13 @@ fn listing_from_tags( loc }); + if farm_pubkey != farm_ref.pubkey { + return Err(TradeListingParseError::InvalidTag(TAG_P.to_string())); + } + Ok(RadrootsListing { d_tag, + farm: farm_ref, product, quantities, prices, @@ -340,6 +363,52 @@ fn listing_from_tags( }) } +fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsListingFarmRef, 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()))?; + 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 { + return Err(TradeListingParseError::InvalidTag(TAG_A.to_string())); + } + 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())); + } + Ok(RadrootsListingFarmRef { pubkey, d_tag }) +} + +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_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_P.to_string()))?; + if value.trim().is_empty() { + return Err(TradeListingParseError::InvalidTag(TAG_P.to_string())); + } + Ok(value) +} + fn clean_value(value: &str) -> Option<String> { let trimmed = value.trim(); if trimmed.is_empty() { diff --git a/trade/src/listing/validation.rs b/trade/src/listing/validation.rs @@ -55,6 +55,9 @@ pub enum TradeListingValidationError { ListingEventNotFound { listing_addr: String }, ListingEventFetchFailed { listing_addr: String }, ParseError { error: TradeListingParseError }, + InvalidSeller, + MissingFarmProfile, + MissingFarmRecord, MissingTitle, MissingDescription, MissingProductType, @@ -83,6 +86,15 @@ impl core::fmt::Display for TradeListingValidationError { TradeListingValidationError::ParseError { error } => { write!(f, "invalid listing data: {error}") } + TradeListingValidationError::InvalidSeller => { + write!(f, "listing author does not match farm pubkey") + } + TradeListingValidationError::MissingFarmProfile => { + write!(f, "missing farm profile") + } + TradeListingValidationError::MissingFarmRecord => { + write!(f, "missing farm record") + } TradeListingValidationError::MissingTitle => write!(f, "missing listing title"), TradeListingValidationError::MissingDescription => { write!(f, "missing listing description") @@ -127,6 +139,9 @@ pub fn validate_listing_event( } let seller_pubkey = event.author.clone(); + if listing.farm.pubkey != seller_pubkey { + return Err(TradeListingValidationError::InvalidSeller); + } let listing_addr = TradeListingAddress { kind: KIND_LISTING as u16, seller_pubkey: seller_pubkey.clone(), @@ -224,13 +239,18 @@ mod tests { kinds::KIND_LISTING, listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, - RadrootsListingLocation, RadrootsListingProduct, RadrootsListingQuantity, + RadrootsListingFarmRef, RadrootsListingLocation, RadrootsListingProduct, + RadrootsListingQuantity, }, }; fn base_listing() -> RadrootsListing { RadrootsListing { d_tag: "listing-1".into(), + farm: RadrootsListingFarmRef { + pubkey: "seller".into(), + d_tag: "farm-1".into(), + }, product: RadrootsListingProduct { key: "coffee".into(), title: "Coffee".into(), @@ -285,7 +305,14 @@ mod tests { author: "seller".into(), created_at: 0, kind: KIND_LISTING, - tags: vec![vec!["d".into(), listing.d_tag.clone()]], + tags: vec![ + vec!["d".into(), listing.d_tag.clone()], + vec!["p".into(), listing.farm.pubkey.clone()], + vec![ + "a".into(), + format!("30340:{}:{}", listing.farm.pubkey, listing.farm.d_tag), + ], + ], content: serde_json::to_string(listing).unwrap(), sig: "sig".into(), } @@ -316,6 +343,8 @@ mod tests { event.content = String::new(); event.tags = vec![ vec!["d".into(), "listing-1".into()], + vec!["p".into(), "seller".into()], + vec!["a".into(), "30340:seller:farm-1".into()], vec!["key".into(), "coffee".into()], vec!["title".into(), "Coffee".into()], vec!["category".into(), "coffee".into()], @@ -351,6 +380,15 @@ mod tests { } #[test] + fn validate_listing_rejects_mismatched_seller() { + let listing = base_listing(); + let mut event = base_event(&listing); + event.author = "other".into(); + let err = validate_listing_event(&event).unwrap_err(); + assert!(matches!(err, TradeListingValidationError::InvalidSeller)); + } + + #[test] fn validate_listing_rejects_missing_inventory() { let mut listing = base_listing(); listing.quantities[0].count = None;