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:
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;