commit 80f133f29c3aca04438523289be57e64c7150282
parent 65de042fbec33cdcf4f51a513232d9dcc3fa25f8
Author: triesap <tyson@radroots.org>
Date: Wed, 24 Dec 2025 19:30:27 +0000
events-codec: add NIP-10 ref tags and centralize listing tag encoding
- Encode/decode comment and reaction refs via NIP-10 E/e,P/p,K/k,A/a tags with legacy fallback
- Fix follow tag parsing and emit optional relay/name fields without placeholders
- Add listing tag builder module plus shared RadrootsEventTagBuilder trait and TS constants export
- Bump workspace rust-version to 1.88.0
Diffstat:
26 files changed, 1088 insertions(+), 185 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
@@ -24,7 +24,7 @@ resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2024"
-rust-version = "1.86.0"
+rust-version = "1.88.0"
license = "AGPL-3.0"
[workspace.dependencies]
diff --git a/events-codec/src/comment/decode.rs b/events-codec/src/comment/decode.rs
@@ -8,7 +8,7 @@ use radroots_events::{
};
use crate::error::EventParseError;
-use crate::event_ref::{find_event_ref_tag, parse_event_ref_tag};
+use crate::event_ref::{find_event_ref_tag, parse_event_ref_tag, parse_nip10_ref_tags};
const DEFAULT_KIND: u32 = 1;
@@ -27,13 +27,20 @@ pub fn comment_from_tags(
return Err(EventParseError::InvalidTag("content"));
}
- let root_tag = find_event_ref_tag(tags, TAG_E_ROOT)
- .ok_or(EventParseError::MissingTag(TAG_E_ROOT))?;
- let root = parse_event_ref_tag(root_tag, TAG_E_ROOT)?;
+ let root = if find_event_ref_tag(tags, "E").is_some() {
+ parse_nip10_ref_tags(tags, "E", "P", "K", "A")?
+ } else if let Some(root_tag) = find_event_ref_tag(tags, TAG_E_ROOT) {
+ parse_event_ref_tag(root_tag, TAG_E_ROOT)?
+ } else {
+ return Err(EventParseError::MissingTag("E"));
+ };
- let parent = match find_event_ref_tag(tags, TAG_E_PREV) {
- Some(tag) => parse_event_ref_tag(tag, TAG_E_PREV)?,
- None => root.clone(),
+ let parent = if find_event_ref_tag(tags, "e").is_some() {
+ parse_nip10_ref_tags(tags, "e", "p", "k", "a")?
+ } else if let Some(tag) = find_event_ref_tag(tags, TAG_E_PREV) {
+ parse_event_ref_tag(tag, TAG_E_PREV)?
+ } else {
+ root.clone()
};
Ok(RadrootsComment {
diff --git a/events-codec/src/comment/encode.rs b/events-codec/src/comment/encode.rs
@@ -3,12 +3,11 @@ use alloc::{string::String, vec::Vec};
use radroots_events::{
comment::RadrootsComment,
- tags::{TAG_E_PREV, TAG_E_ROOT},
RadrootsNostrEventRef,
};
use crate::error::EventEncodeError;
-use crate::event_ref::build_event_ref_tag;
+use crate::event_ref::push_nip10_ref_tags;
use crate::wire::WireEventParts;
const DEFAULT_KIND: u32 = 1;
@@ -31,9 +30,19 @@ pub fn comment_build_tags(comment: &RadrootsComment) -> Result<Vec<Vec<String>>,
validate_ref(&comment.root, "root.id", "root.author")?;
validate_ref(&comment.parent, "parent.id", "parent.author")?;
- let mut tags = Vec::with_capacity(2);
- tags.push(build_event_ref_tag(TAG_E_ROOT, &comment.root));
- tags.push(build_event_ref_tag(TAG_E_PREV, &comment.parent));
+ let root_has_addr = comment
+ .root
+ .d_tag
+ .as_deref()
+ .map_or(false, |v| !v.is_empty());
+ let parent_has_addr = comment
+ .parent
+ .d_tag
+ .as_deref()
+ .map_or(false, |v| !v.is_empty());
+ let mut tags = Vec::with_capacity(6 + usize::from(root_has_addr) + usize::from(parent_has_addr));
+ push_nip10_ref_tags(&mut tags, &comment.root, "E", "P", "K", "A");
+ push_nip10_ref_tags(&mut tags, &comment.parent, "e", "p", "k", "a");
Ok(tags)
}
diff --git a/events-codec/src/event_ref.rs b/events-codec/src/event_ref.rs
@@ -66,3 +66,122 @@ pub fn find_event_ref_tag<'a>(
tags.iter()
.find(|t| t.get(0).map(|s| s.as_str()) == Some(tag_name))
}
+
+pub fn push_nip10_ref_tags(
+ tags: &mut Vec<Vec<String>>,
+ event: &RadrootsNostrEventRef,
+ tag_e: &'static str,
+ tag_p: &'static str,
+ tag_k: &'static str,
+ tag_a: &'static str,
+) {
+ let relays_len = event.relays.as_ref().map(|r| r.len()).unwrap_or(0);
+ let kind_str = event.kind.to_string();
+
+ let mut e_tag = Vec::with_capacity(2 + relays_len);
+ e_tag.push(tag_e.to_string());
+ e_tag.push(event.id.clone());
+ if let Some(relays) = &event.relays {
+ e_tag.extend(relays.iter().cloned());
+ }
+ tags.push(e_tag);
+
+ let mut p_tag = Vec::with_capacity(2);
+ p_tag.push(tag_p.to_string());
+ p_tag.push(event.author.clone());
+ tags.push(p_tag);
+
+ let mut k_tag = Vec::with_capacity(2);
+ k_tag.push(tag_k.to_string());
+ k_tag.push(kind_str.clone());
+ tags.push(k_tag);
+
+ if let Some(d_tag) = event.d_tag.as_deref().filter(|v| !v.is_empty()) {
+ let mut addr = String::with_capacity(kind_str.len() + event.author.len() + d_tag.len() + 2);
+ addr.push_str(&kind_str);
+ addr.push(':');
+ addr.push_str(&event.author);
+ addr.push(':');
+ addr.push_str(d_tag);
+
+ let mut a_tag = Vec::with_capacity(2 + relays_len);
+ a_tag.push(tag_a.to_string());
+ a_tag.push(addr);
+ if let Some(relays) = &event.relays {
+ a_tag.extend(relays.iter().cloned());
+ }
+ tags.push(a_tag);
+ }
+}
+
+pub fn parse_nip10_ref_tags(
+ tags: &[Vec<String>],
+ tag_e: &'static str,
+ tag_p: &'static str,
+ tag_k: &'static str,
+ tag_a: &'static str,
+) -> Result<RadrootsNostrEventRef, EventParseError> {
+ let e_tag = find_event_ref_tag(tags, tag_e).ok_or(EventParseError::MissingTag(tag_e))?;
+ let id = e_tag.get(1).ok_or(EventParseError::InvalidTag(tag_e))?;
+ if id.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(tag_e));
+ }
+ let relays = if e_tag.len() > 2 {
+ Some(e_tag[2..].to_vec())
+ } else {
+ None
+ };
+
+ let p_tag = find_event_ref_tag(tags, tag_p).ok_or(EventParseError::MissingTag(tag_p))?;
+ let author = p_tag.get(1).ok_or(EventParseError::InvalidTag(tag_p))?;
+ if author.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(tag_p));
+ }
+
+ let k_tag = find_event_ref_tag(tags, tag_k).ok_or(EventParseError::MissingTag(tag_k))?;
+ let kind_key = k_tag.get(1).ok_or(EventParseError::InvalidTag(tag_k))?;
+ let kind: u32 = kind_key
+ .parse()
+ .map_err(|e| EventParseError::InvalidNumber(tag_k, e))?;
+
+ let mut d_tag: Option<String> = None;
+ let mut addr_relays: Option<Vec<String>> = None;
+ for tag in tags
+ .iter()
+ .filter(|t| t.get(0).map(|s| s.as_str()) == Some(tag_a))
+ {
+ let value = match tag.get(1) {
+ Some(v) => v,
+ None => continue,
+ };
+ let mut parts = value.splitn(3, ':');
+ let kind_part = parts.next();
+ let author_part = parts.next();
+ let d_part = parts.next();
+ if kind_part != Some(kind_key.as_str()) || author_part != Some(author.as_str()) {
+ continue;
+ }
+ if let Some(d) = d_part {
+ if !d.is_empty() {
+ d_tag = Some(d.to_string());
+ }
+ }
+ if tag.len() > 2 {
+ addr_relays = Some(tag[2..].to_vec());
+ }
+ break;
+ }
+
+ let relays = match relays {
+ Some(v) if !v.is_empty() => Some(v),
+ _ => addr_relays,
+ };
+
+ Ok(RadrootsNostrEventRef {
+ id: id.clone(),
+ author: author.clone(),
+ kind,
+ d_tag,
+ relays,
+ })
+}
diff --git a/events-codec/src/follow/decode.rs b/events-codec/src/follow/decode.rs
@@ -10,6 +10,10 @@ use crate::error::EventParseError;
const DEFAULT_KIND: u32 = 3;
+fn looks_like_ws_relay(s: &str) -> bool {
+ s.starts_with("ws://") || s.starts_with("wss://")
+}
+
fn parse_follow_tag(
tag: &[String],
published_at: u32,
@@ -18,8 +22,15 @@ fn parse_follow_tag(
return Err(EventParseError::InvalidTag("p"));
}
let public_key = tag.get(1).ok_or(EventParseError::InvalidTag("p"))?;
- let relay_url = tag.get(2).filter(|s| !s.is_empty()).cloned();
- let contact_name = tag.get(3).filter(|s| !s.is_empty()).cloned();
+ let (relay_url, contact_name) = match tag.get(2).filter(|s| !s.is_empty()) {
+ Some(value) if looks_like_ws_relay(value) => (
+ Some(value.clone()),
+ tag.get(3).filter(|s| !s.is_empty()).cloned(),
+ ),
+ Some(value) => (None, Some(value.clone())),
+ None => (None, tag.get(3).filter(|s| !s.is_empty()).cloned()),
+ };
+
let published_at = match tag.get(4) {
Some(v) => v
.parse()
diff --git a/events-codec/src/follow/encode.rs b/events-codec/src/follow/encode.rs
@@ -12,12 +12,17 @@ fn follow_tag(profile: &RadrootsFollowProfile) -> Result<Vec<String>, EventEncod
if profile.public_key.trim().is_empty() {
return Err(EventEncodeError::EmptyRequiredField("follow.public_key"));
}
- let mut tag = Vec::with_capacity(5);
+ let relay = profile.relay_url.as_ref().filter(|v| !v.is_empty());
+ let name = profile.contact_name.as_ref().filter(|v| !v.is_empty());
+ let mut tag = Vec::with_capacity(2 + usize::from(relay.is_some()) + usize::from(name.is_some()));
tag.push("p".to_string());
tag.push(profile.public_key.clone());
- tag.push(profile.relay_url.clone().unwrap_or_default());
- tag.push(profile.contact_name.clone().unwrap_or_default());
- tag.push(profile.published_at.to_string());
+ if let Some(relay) = relay {
+ tag.push(relay.clone());
+ }
+ if let Some(name) = name {
+ tag.push(name.clone());
+ }
Ok(tag)
}
diff --git a/events-codec/src/job/feedback/encode.rs b/events-codec/src/job/feedback/encode.rs
@@ -22,6 +22,10 @@ pub fn job_feedback_build_tags(fb: &RadrootsJobFeedback) -> Vec<Vec<String>> {
}
tags.push(st);
+ if let Some(pay) = &fb.payment {
+ push_amount_tag_msat(&mut tags, pay.amount_sat, pay.bolt11.clone());
+ }
+
let mut e = Vec::with_capacity(3);
e.push("e".to_string());
e.push(fb.request_event.id.clone());
@@ -34,10 +38,6 @@ pub fn job_feedback_build_tags(fb: &RadrootsJobFeedback) -> Vec<Vec<String>> {
tags.push(vec!["p".into(), p.clone()]);
}
- if let Some(pay) = &fb.payment {
- push_amount_tag_msat(&mut tags, pay.amount_sat, pay.bolt11.clone());
- }
-
if fb.encrypted {
tags.push(vec!["encrypted".into()]);
}
diff --git a/events-codec/src/job/request/encode.rs b/events-codec/src/job/request/encode.rs
@@ -1,7 +1,7 @@
use radroots_events::{job_request::RadrootsJobRequest, kinds::is_request_kind};
use crate::job::encode::{JobEncodeError, WireEventParts, canonicalize_tags};
-use crate::job::util::{job_input_type_tag, push_bid_tag_msat};
+use crate::job::util::{job_input_type_tag, push_bid_tag_sat};
#[cfg(not(feature = "std"))]
use alloc::{string::{String, ToString}, vec, vec::Vec};
@@ -41,7 +41,7 @@ pub fn job_request_build_tags(req: &RadrootsJobRequest) -> Vec<Vec<String>> {
}
if let Some(bid_sat) = req.bid_sat {
- push_bid_tag_msat(&mut tags, bid_sat);
+ push_bid_tag_sat(&mut tags, bid_sat);
}
for r in &req.relays {
diff --git a/events-codec/src/job/util.rs b/events-codec/src/job/util.rs
@@ -221,21 +221,16 @@ pub fn parse_bid_tag_sat(tags: &[Vec<String>]) -> Result<Option<u32>, JobParseEr
Some(b) => b,
None => return Ok(None),
};
- let msat_s = bid.get(1).ok_or(JobParseError::InvalidTag("bid"))?;
- let msat_u64: u64 = msat_s
+ let sat_s = bid.get(1).ok_or(JobParseError::InvalidTag("bid"))?;
+ let sat_u64: u64 = sat_s
.parse()
.map_err(|e| JobParseError::InvalidNumber("bid", e))?;
- if msat_u64 % 1000 != 0 {
- return Err(JobParseError::NonWholeSats("bid"));
- }
- let sat_u64 = msat_u64 / 1000;
if sat_u64 > (u32::MAX as u64) {
return Err(JobParseError::AmountOverflow("bid"));
}
Ok(Some(sat_u64 as u32))
}
-pub fn push_bid_tag_msat(tags: &mut Vec<Vec<String>>, bid_sat: u32) {
- let msat = (bid_sat as u64) * 1000;
- tags.push(vec!["bid".into(), msat.to_string()]);
+pub fn push_bid_tag_sat(tags: &mut Vec<Vec<String>>, bid_sat: u32) {
+ tags.push(vec!["bid".into(), bid_sat.to_string()]);
}
diff --git a/events-codec/src/lib.rs b/events-codec/src/lib.rs
@@ -7,6 +7,7 @@ pub mod error;
pub mod event_ref;
pub mod job;
pub mod profile;
+pub mod tag_builders;
pub mod wire;
pub mod comment;
@@ -14,8 +15,9 @@ pub mod follow;
pub mod post;
pub mod reaction;
-#[cfg(feature = "serde_json")]
pub mod listing;
#[cfg(feature = "serde_json")]
pub mod relay_document;
+
+pub use tag_builders::RadrootsEventTagBuilder;
diff --git a/events-codec/src/listing/encode.rs b/events-codec/src/listing/encode.rs
@@ -1,29 +1,28 @@
-#![cfg(feature = "serde_json")]
-
#[cfg(not(feature = "std"))]
-use alloc::{string::{String, ToString}, vec, vec::Vec};
+use alloc::vec::Vec;
+#[cfg(not(feature = "std"))]
+use alloc::string::String;
-use radroots_events::{listing::RadrootsListing, tags::TAG_D};
+use radroots_events::listing::RadrootsListing;
use crate::error::EventEncodeError;
+use crate::listing::tags::listing_tags;
+#[cfg(feature = "serde_json")]
use crate::wire::WireEventParts;
+#[cfg(feature = "serde_json")]
const DEFAULT_KIND: u32 = 30402;
pub fn listing_build_tags(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, EventEncodeError> {
- let d_tag = listing.d_tag.trim();
- if d_tag.is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("d"));
- }
- let mut tags = Vec::with_capacity(1);
- tags.push(vec![TAG_D.to_string(), d_tag.to_string()]);
- Ok(tags)
+ listing_tags(listing)
}
+#[cfg(feature = "serde_json")]
pub fn to_wire_parts(listing: &RadrootsListing) -> Result<WireEventParts, EventEncodeError> {
to_wire_parts_with_kind(listing, DEFAULT_KIND)
}
+#[cfg(feature = "serde_json")]
pub fn to_wire_parts_with_kind(
listing: &RadrootsListing,
kind: u32,
diff --git a/events-codec/src/listing/mod.rs b/events-codec/src/listing/mod.rs
@@ -1,2 +1,3 @@
pub mod decode;
pub mod encode;
+pub mod tags;
diff --git a/events-codec/src/listing/tags.rs b/events-codec/src/listing/tags.rs
@@ -0,0 +1,459 @@
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::{format, string::{String, ToString}, vec, vec::Vec};
+
+use core::cmp;
+
+use radroots_core::RadrootsCoreQuantityPrice;
+#[cfg(feature = "serde_json")]
+use radroots_core::{
+ RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCorePercent, RadrootsCoreQuantity,
+};
+use radroots_events::listing::{
+ RadrootsListing, RadrootsListingDiscount, RadrootsListingImage, RadrootsListingLocation,
+ RadrootsListingQuantity,
+};
+use radroots_events::tags::TAG_D;
+
+use crate::error::EventEncodeError;
+
+const TAG_QUANTITY: &str = "quantity";
+const TAG_PRICE: &str = "price";
+const TAG_PRICE_DISCOUNT_PREFIX: &str = "price-discount-";
+const TAG_LOCATION: &str = "location";
+const TAG_IMAGE: &str = "image";
+const TAG_GEOHASH: &str = "g";
+const TAG_LABEL: &str = "l";
+const TAG_LABEL_NS: &str = "L";
+const TAG_DD: &str = "dd";
+const TAG_DD_LAT: &str = "dd.lat";
+const TAG_DD_LON: &str = "dd.lon";
+
+const GEOHASH_PRECISION_DEFAULT: usize = 9;
+const DD_MAX_RESOLUTION_DEFAULT: u32 = 9;
+
+const BASE32_CODES: &[u8; 32] = b"0123456789bcdefghjkmnpqrstuvwxyz";
+
+#[derive(Clone, Copy, Debug)]
+pub struct ListingTagOptions {
+ pub geohash_precision: usize,
+ pub dd_max_resolution: u32,
+ pub include_geohash: bool,
+ pub include_gps: bool,
+}
+
+impl Default for ListingTagOptions {
+ fn default() -> Self {
+ Self {
+ geohash_precision: GEOHASH_PRECISION_DEFAULT,
+ dd_max_resolution: DD_MAX_RESOLUTION_DEFAULT,
+ include_geohash: true,
+ include_gps: true,
+ }
+ }
+}
+
+pub fn listing_tags(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ listing_tags_with_options(listing, ListingTagOptions::default())
+}
+
+pub fn listing_tags_with_options(
+ listing: &RadrootsListing,
+ options: ListingTagOptions,
+) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ let d_tag = listing.d_tag.trim();
+ if d_tag.is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("d"));
+ }
+
+ let mut tags: Vec<Vec<String>> = Vec::new();
+ tags.push(vec![TAG_D.to_string(), d_tag.to_string()]);
+
+ let product = &listing.product;
+ push_tag_value(&mut tags, "key", &product.key);
+ push_tag_value(&mut tags, "title", &product.title);
+ push_tag_value(&mut tags, "category", &product.category);
+ if let Some(summary) = product.summary.as_deref() {
+ push_tag_value(&mut tags, "summary", summary);
+ }
+ if let Some(process) = product.process.as_deref() {
+ push_tag_value(&mut tags, "process", process);
+ }
+ if let Some(lot) = product.lot.as_deref() {
+ push_tag_value(&mut tags, "lot", lot);
+ }
+ if let Some(location) = product.location.as_deref() {
+ push_tag_value(&mut tags, "location", location);
+ }
+ if let Some(profile) = product.profile.as_deref() {
+ push_tag_value(&mut tags, "profile", profile);
+ }
+ if let Some(year) = product.year.as_deref() {
+ push_tag_value(&mut tags, "year", year);
+ }
+
+ for quantity in &listing.quantities {
+ tags.push(tag_listing_quantity(quantity));
+ }
+
+ for price in &listing.prices {
+ tags.push(tag_listing_price(price));
+ }
+
+ if let Some(discounts) = &listing.discounts {
+ for discount in discounts {
+ let (kind, payload) = discount_tag_parts(discount)?;
+ tags.push(vec![format!("{TAG_PRICE_DISCOUNT_PREFIX}{kind}"), payload]);
+ }
+ }
+
+ if let Some(location) = &listing.location {
+ if let Some(primary) = clean_value(&location.primary) {
+ let mut tag = Vec::with_capacity(5);
+ tag.push(TAG_LOCATION.to_string());
+ tag.push(primary);
+ if let Some(city) = location.city.as_deref().and_then(clean_value) {
+ tag.push(city);
+ }
+ if let Some(region) = location.region.as_deref().and_then(clean_value) {
+ tag.push(region);
+ }
+ if let Some(country) = location.country.as_deref().and_then(clean_value) {
+ tag.push(country);
+ }
+ tags.push(tag);
+ if options.include_geohash || options.include_gps {
+ push_location_geotags(&mut tags, location, options);
+ }
+ }
+ }
+
+ if let Some(images) = &listing.images {
+ for image in images {
+ if let Some(tag) = tag_listing_image(image) {
+ tags.push(tag);
+ }
+ }
+ }
+
+ Ok(tags)
+}
+
+fn tag_listing_quantity(quantity: &RadrootsListingQuantity) -> Vec<String> {
+ let mut tag = Vec::with_capacity(5);
+ tag.push(TAG_QUANTITY.to_string());
+ tag.push(quantity.value.amount.to_string());
+ tag.push(quantity.value.unit.code().to_string());
+ let label = quantity
+ .label
+ .as_deref()
+ .and_then(clean_value)
+ .or_else(|| quantity.value.label.as_deref().and_then(clean_value));
+ if let Some(label) = label {
+ tag.push(label);
+ }
+ if let Some(count) = quantity.count {
+ tag.push(count.to_string());
+ }
+ tag
+}
+
+fn tag_listing_price(price: &RadrootsCoreQuantityPrice) -> Vec<String> {
+ let mut tag = Vec::with_capacity(6);
+ tag.push(TAG_PRICE.to_string());
+ tag.push(price.amount.amount.to_string());
+ tag.push(price.amount.currency.as_str().to_ascii_lowercase());
+ tag.push(price.quantity.amount.to_string());
+ tag.push(price.quantity.unit.code().to_string());
+ if let Some(label) = price
+ .quantity
+ .label
+ .as_deref()
+ .and_then(clean_value)
+ {
+ tag.push(label);
+ }
+ tag
+}
+
+fn tag_listing_image(image: &RadrootsListingImage) -> Option<Vec<String>> {
+ let url = clean_value(&image.url)?;
+ let mut tag = Vec::with_capacity(3);
+ tag.push(TAG_IMAGE.to_string());
+ tag.push(url);
+ if let Some(size) = &image.size {
+ tag.push(format!("{}x{}", size.w, size.h));
+ }
+ Some(tag)
+}
+
+fn push_location_geotags(
+ tags: &mut Vec<Vec<String>>,
+ location: &RadrootsListingLocation,
+ options: ListingTagOptions,
+) {
+ let mut lat = location.lat.filter(|value| value.is_finite());
+ let mut lon = location.lng.filter(|value| value.is_finite());
+ let location_geohash = location.geohash.as_deref().and_then(clean_value);
+
+ let geohash = if options.include_geohash {
+ if let (Some(lat), Some(lon)) = (lat, lon) {
+ let precision = options.geohash_precision.max(1);
+ Some(geohash_encode(lat, lon, precision))
+ } else {
+ location_geohash.clone()
+ }
+ } else {
+ None
+ };
+
+ if let Some(geohash) = geohash.as_ref() {
+ for idx in (1..=geohash.len()).rev() {
+ tags.push(vec![TAG_GEOHASH.to_string(), geohash[..idx].to_string()]);
+ }
+ }
+
+ if options.include_gps {
+ if lat.is_none() || lon.is_none() {
+ if let Some(geohash) = geohash.as_deref().or(location_geohash.as_deref()) {
+ if let Some((decoded_lat, decoded_lon)) = geohash_decode(geohash) {
+ lat = Some(decoded_lat);
+ lon = Some(decoded_lon);
+ }
+ }
+ }
+ if let (Some(lat), Some(lon)) = (lat, lon) {
+ tags.push(vec![
+ TAG_LABEL.to_string(),
+ format!("{lat}, {lon}"),
+ TAG_DD.to_string(),
+ ]);
+ let max_resolution = options.dd_max_resolution.max(1);
+ let lat_resolution = calculate_resolution(lat, max_resolution);
+ let lon_resolution = calculate_resolution(lon, max_resolution);
+ tags.push(vec![TAG_LABEL_NS.to_string(), TAG_DD_LAT.to_string()]);
+ for idx in (1..=lat_resolution).rev() {
+ let truncated = truncate_to_resolution(lat, idx);
+ tags.push(vec![
+ TAG_LABEL.to_string(),
+ truncated.to_string(),
+ TAG_DD_LAT.to_string(),
+ ]);
+ }
+ tags.push(vec![TAG_LABEL_NS.to_string(), TAG_DD_LON.to_string()]);
+ for idx in (1..=lon_resolution).rev() {
+ let truncated = truncate_to_resolution(lon, idx);
+ tags.push(vec![
+ TAG_LABEL.to_string(),
+ truncated.to_string(),
+ TAG_DD_LON.to_string(),
+ ]);
+ }
+ }
+ }
+}
+
+fn calculate_resolution(value: f64, max: u32) -> u32 {
+ if value.fract() == 0.0 {
+ return 1;
+ }
+ let s = value.to_string();
+ let decimals = s
+ .split('.')
+ .nth(1)
+ .map(|v| v.len() as u32)
+ .unwrap_or(0);
+ let bounded = cmp::min(decimals, max);
+ if bounded == 0 { 1 } else { bounded }
+}
+
+fn truncate_to_resolution(value: f64, resolution: u32) -> f64 {
+ let multiplier = 10_f64.powi(resolution as i32);
+ (value * multiplier).floor() / multiplier
+}
+
+fn geohash_encode(latitude: f64, longitude: f64, precision: usize) -> String {
+ if precision == 0 {
+ return String::new();
+ }
+ let mut out = String::with_capacity(precision);
+ let mut bits: u8 = 0;
+ let mut bits_total: u8 = 0;
+ let mut hash_value: u8 = 0;
+ let mut max_lat = 90.0;
+ let mut min_lat = -90.0;
+ let mut max_lon = 180.0;
+ let mut min_lon = -180.0;
+
+ while out.len() < precision {
+ if bits_total % 2 == 0 {
+ let mid = (max_lon + min_lon) / 2.0;
+ if longitude > mid {
+ hash_value = (hash_value << 1) + 1;
+ min_lon = mid;
+ } else {
+ hash_value <<= 1;
+ max_lon = mid;
+ }
+ } else {
+ let mid = (max_lat + min_lat) / 2.0;
+ if latitude > mid {
+ hash_value = (hash_value << 1) + 1;
+ min_lat = mid;
+ } else {
+ hash_value <<= 1;
+ max_lat = mid;
+ }
+ }
+ bits += 1;
+ bits_total += 1;
+ if bits == 5 {
+ out.push(BASE32_CODES[hash_value as usize] as char);
+ bits = 0;
+ hash_value = 0;
+ }
+ }
+ out
+}
+
+fn geohash_decode(hash: &str) -> Option<(f64, f64)> {
+ let (min_lat, min_lon, max_lat, max_lon) = geohash_decode_bbox(hash)?;
+ let lat = (min_lat + max_lat) / 2.0;
+ let lon = (min_lon + max_lon) / 2.0;
+ Some((lat, lon))
+}
+
+fn geohash_decode_bbox(hash: &str) -> Option<(f64, f64, f64, f64)> {
+ let mut is_lon = true;
+ let mut max_lat = 90.0;
+ let mut min_lat = -90.0;
+ let mut max_lon = 180.0;
+ let mut min_lon = -180.0;
+
+ for b in hash.bytes() {
+ let value = base32_value(b)?;
+ for bits in (0..5).rev() {
+ let bit = (value >> bits) & 1;
+ if is_lon {
+ let mid = (max_lon + min_lon) / 2.0;
+ if bit == 1 {
+ min_lon = mid;
+ } else {
+ max_lon = mid;
+ }
+ } else {
+ let mid = (max_lat + min_lat) / 2.0;
+ if bit == 1 {
+ min_lat = mid;
+ } else {
+ max_lat = mid;
+ }
+ }
+ is_lon = !is_lon;
+ }
+ }
+ Some((min_lat, min_lon, max_lat, max_lon))
+}
+
+fn base32_value(c: u8) -> Option<u8> {
+ let needle = c.to_ascii_lowercase();
+ BASE32_CODES
+ .iter()
+ .position(|&b| b == needle)
+ .map(|idx| idx as u8)
+}
+
+fn push_tag_value(tags: &mut Vec<Vec<String>>, key: &str, value: &str) {
+ if let Some(cleaned) = clean_value(value) {
+ tags.push(vec![key.to_string(), cleaned]);
+ }
+}
+
+fn clean_value(value: &str) -> Option<String> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_string())
+ }
+}
+
+fn discount_tag_parts(
+ discount: &RadrootsListingDiscount,
+) -> Result<(&'static str, String), EventEncodeError> {
+ #[cfg(feature = "serde_json")]
+ {
+ let (kind, payload) = match discount {
+ RadrootsListingDiscount::Quantity {
+ ref_quantity,
+ threshold,
+ value,
+ } => (
+ "quantity",
+ serde_json::to_string(&QuantityDiscountPayload {
+ ref_quantity: ref_quantity.clone(),
+ threshold: threshold.clone(),
+ value: value.clone(),
+ }),
+ ),
+ RadrootsListingDiscount::Mass { threshold, value } => (
+ "mass",
+ serde_json::to_string(&MassDiscountPayload {
+ threshold: threshold.clone(),
+ value: value.clone(),
+ }),
+ ),
+ RadrootsListingDiscount::Subtotal { threshold, value } => (
+ "subtotal",
+ serde_json::to_string(&SubtotalDiscountPayload {
+ threshold: threshold.clone(),
+ value: value.clone(),
+ }),
+ ),
+ RadrootsListingDiscount::Total { total_min, value } => (
+ "total",
+ serde_json::to_string(&TotalDiscountPayload {
+ total_min: total_min.clone(),
+ value: value.clone(),
+ }),
+ ),
+ };
+ let payload = payload.map_err(|_| EventEncodeError::Json)?;
+ return Ok((kind, payload));
+ }
+ #[cfg(not(feature = "serde_json"))]
+ {
+ let _ = discount;
+ Err(EventEncodeError::Json)
+ }
+}
+
+#[cfg(feature = "serde_json")]
+#[derive(serde::Serialize, Clone)]
+struct QuantityDiscountPayload {
+ ref_quantity: String,
+ threshold: RadrootsCoreQuantity,
+ value: RadrootsCoreMoney,
+}
+
+#[cfg(feature = "serde_json")]
+#[derive(serde::Serialize, Clone)]
+struct MassDiscountPayload {
+ threshold: RadrootsCoreQuantity,
+ value: RadrootsCoreMoney,
+}
+
+#[cfg(feature = "serde_json")]
+#[derive(serde::Serialize, Clone)]
+struct SubtotalDiscountPayload {
+ threshold: RadrootsCoreMoney,
+ value: RadrootsCoreDiscountValue,
+}
+
+#[cfg(feature = "serde_json")]
+#[derive(serde::Serialize, Clone)]
+struct TotalDiscountPayload {
+ total_min: RadrootsCoreMoney,
+ value: RadrootsCorePercent,
+}
diff --git a/events-codec/src/reaction/decode.rs b/events-codec/src/reaction/decode.rs
@@ -8,7 +8,7 @@ use radroots_events::{
};
use crate::error::EventParseError;
-use crate::event_ref::{find_event_ref_tag, parse_event_ref_tag};
+use crate::event_ref::{find_event_ref_tag, parse_event_ref_tag, parse_nip10_ref_tags};
const DEFAULT_KIND: u32 = 7;
@@ -26,9 +26,13 @@ pub fn reaction_from_tags(
if content.trim().is_empty() {
return Err(EventParseError::InvalidTag("content"));
}
- let root_tag = find_event_ref_tag(tags, TAG_E_ROOT)
- .ok_or(EventParseError::MissingTag(TAG_E_ROOT))?;
- let root = parse_event_ref_tag(root_tag, TAG_E_ROOT)?;
+ let root = if find_event_ref_tag(tags, "e").is_some() {
+ parse_nip10_ref_tags(tags, "e", "p", "k", "a")?
+ } else if let Some(root_tag) = find_event_ref_tag(tags, TAG_E_ROOT) {
+ parse_event_ref_tag(root_tag, TAG_E_ROOT)?
+ } else {
+ return Err(EventParseError::MissingTag("e"));
+ };
Ok(RadrootsReaction {
root,
content: content.to_string(),
diff --git a/events-codec/src/reaction/encode.rs b/events-codec/src/reaction/encode.rs
@@ -3,12 +3,11 @@ use alloc::{string::String, vec::Vec};
use radroots_events::{
reaction::RadrootsReaction,
- tags::TAG_E_ROOT,
RadrootsNostrEventRef,
};
use crate::error::EventEncodeError;
-use crate::event_ref::build_event_ref_tag;
+use crate::event_ref::push_nip10_ref_tags;
use crate::wire::WireEventParts;
const DEFAULT_KIND: u32 = 7;
@@ -27,8 +26,13 @@ pub fn reaction_build_tags(
reaction: &RadrootsReaction,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
validate_ref(&reaction.root)?;
- let mut tags = Vec::with_capacity(1);
- tags.push(build_event_ref_tag(TAG_E_ROOT, &reaction.root));
+ let has_addr = reaction
+ .root
+ .d_tag
+ .as_deref()
+ .map_or(false, |v| !v.is_empty());
+ let mut tags = Vec::with_capacity(3 + usize::from(has_addr));
+ push_nip10_ref_tags(&mut tags, &reaction.root, "e", "p", "k", "a");
Ok(tags)
}
diff --git a/events-codec/src/tag_builders.rs b/events-codec/src/tag_builders.rs
@@ -0,0 +1,108 @@
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+
+use core::convert::Infallible;
+
+use radroots_events::{
+ comment::RadrootsComment,
+ follow::RadrootsFollow,
+ job_feedback::RadrootsJobFeedback,
+ job_request::RadrootsJobRequest,
+ job_result::RadrootsJobResult,
+ listing::RadrootsListing,
+ post::RadrootsPost,
+ profile::RadrootsProfile,
+ reaction::RadrootsReaction,
+};
+
+use crate::comment::encode::comment_build_tags;
+use crate::error::EventEncodeError;
+use crate::follow::encode::follow_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;
+use crate::job::result::encode::job_result_build_tags;
+use crate::listing::tags::listing_tags;
+use crate::reaction::encode::reaction_build_tags;
+
+pub trait RadrootsEventTagBuilder {
+ type Error;
+ fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error>;
+}
+
+impl RadrootsEventTagBuilder for RadrootsListing {
+ type Error = EventEncodeError;
+
+ fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> {
+ listing_tags(self)
+ }
+}
+
+impl RadrootsEventTagBuilder for RadrootsComment {
+ type Error = EventEncodeError;
+
+ fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> {
+ comment_build_tags(self)
+ }
+}
+
+impl RadrootsEventTagBuilder for RadrootsReaction {
+ type Error = EventEncodeError;
+
+ fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> {
+ reaction_build_tags(self)
+ }
+}
+
+impl RadrootsEventTagBuilder for RadrootsFollow {
+ type Error = EventEncodeError;
+
+ fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> {
+ follow_build_tags(self)
+ }
+}
+
+impl RadrootsEventTagBuilder for RadrootsJobRequest {
+ type Error = JobEncodeError;
+
+ fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> {
+ if self.encrypted && self.providers.is_empty() {
+ return Err(JobEncodeError::MissingProvidersForEncrypted);
+ }
+ Ok(job_request_build_tags(self))
+ }
+}
+
+impl RadrootsEventTagBuilder for RadrootsJobResult {
+ type Error = JobEncodeError;
+
+ fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> {
+ Ok(job_result_build_tags(self))
+ }
+}
+
+impl RadrootsEventTagBuilder for RadrootsJobFeedback {
+ type Error = JobEncodeError;
+
+ fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> {
+ Ok(job_feedback_build_tags(self))
+ }
+}
+
+impl RadrootsEventTagBuilder for RadrootsProfile {
+ type Error = Infallible;
+
+ fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> {
+ Ok(Vec::new())
+ }
+}
+
+impl RadrootsEventTagBuilder for RadrootsPost {
+ type Error = Infallible;
+
+ fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> {
+ Ok(Vec::new())
+ }
+}
diff --git a/events-codec/tests/comment.rs b/events-codec/tests/comment.rs
@@ -6,7 +6,7 @@ use radroots_events::tags::{TAG_E_PREV, TAG_E_ROOT};
use radroots_events_codec::comment::decode::comment_from_tags;
use radroots_events_codec::comment::encode::{comment_build_tags, to_wire_parts};
use radroots_events_codec::error::{EventEncodeError, EventParseError};
-use radroots_events_codec::event_ref::build_event_ref_tag;
+use radroots_events_codec::event_ref::{build_event_ref_tag, push_nip10_ref_tags};
fn assert_event_ref_fields(
actual: &radroots_events::RadrootsNostrEventRef,
@@ -66,13 +66,24 @@ fn comment_to_wire_parts_requires_content() {
#[test]
fn comment_roundtrip_from_tags_with_parent() {
- let root = common::event_ref("root", "author", 1);
- let parent = common::event_ref("parent", "author", 1);
-
- let tags = vec![
- build_event_ref_tag(TAG_E_ROOT, &root),
- build_event_ref_tag(TAG_E_PREV, &parent),
- ];
+ let root = common::event_ref_with_d(
+ "root",
+ "author",
+ 1,
+ "root-d",
+ Some(vec!["wss://relay".to_string()]),
+ );
+ let parent = common::event_ref_with_d(
+ "parent",
+ "author",
+ 1,
+ "parent-d",
+ Some(vec!["wss://relay-2".to_string()]),
+ );
+
+ let mut tags = Vec::new();
+ push_nip10_ref_tags(&mut tags, &root, "E", "P", "K", "A");
+ push_nip10_ref_tags(&mut tags, &parent, "e", "p", "k", "a");
let comment = comment_from_tags(1, &tags, "hello").unwrap();
@@ -84,7 +95,8 @@ fn comment_roundtrip_from_tags_with_parent() {
#[test]
fn comment_from_tags_defaults_parent_to_root() {
let root = common::event_ref("root", "author", 1);
- let tags = vec![build_event_ref_tag(TAG_E_ROOT, &root)];
+ let mut tags = Vec::new();
+ push_nip10_ref_tags(&mut tags, &root, "E", "P", "K", "A");
let comment = comment_from_tags(1, &tags, "hello").unwrap();
@@ -93,11 +105,27 @@ fn comment_from_tags_defaults_parent_to_root() {
}
#[test]
+fn comment_roundtrip_from_legacy_tags() {
+ let root = common::event_ref("root", "author", 1);
+ let parent = common::event_ref("parent", "author", 1);
+
+ let tags = vec![
+ build_event_ref_tag(TAG_E_ROOT, &root),
+ build_event_ref_tag(TAG_E_PREV, &parent),
+ ];
+
+ let comment = comment_from_tags(1, &tags, "hello").unwrap();
+
+ assert_event_ref_fields(&comment.root, &root);
+ assert_event_ref_fields(&comment.parent, &parent);
+}
+
+#[test]
fn comment_from_tags_requires_root_tag() {
let tags = vec![vec!["p".to_string(), "x".to_string()]];
let err = comment_from_tags(1, &tags, "hello").unwrap_err();
- assert!(matches!(err, EventParseError::MissingTag(TAG_E_ROOT)));
+ assert!(matches!(err, EventParseError::MissingTag("E")));
}
#[test]
diff --git a/events-codec/tests/follow.rs b/events-codec/tests/follow.rs
@@ -25,7 +25,6 @@ fn follow_to_wire_parts_builds_p_tags() {
assert_eq!(tag[1], "pubkey");
assert_eq!(tag[2], "wss://relay");
assert_eq!(tag[3], "alice");
- assert_eq!(tag[4], "42");
}
#[test]
@@ -59,6 +58,21 @@ fn follow_from_tags_defaults_published_at() {
}
#[test]
+fn follow_from_tags_accepts_contact_without_relay() {
+ let tags = vec![vec![
+ "p".to_string(),
+ "pubkey".to_string(),
+ "alice".to_string(),
+ ]];
+
+ let follow = follow_from_tags(3, &tags, 123).unwrap();
+ assert_eq!(follow.list[0].published_at, 123);
+ assert_eq!(follow.list[0].public_key, "pubkey");
+ assert!(follow.list[0].relay_url.is_none());
+ assert_eq!(follow.list[0].contact_name.as_deref(), Some("alice"));
+}
+
+#[test]
fn follow_from_tags_uses_tag_published_at() {
let tags = vec![vec![
"p".to_string(),
diff --git a/events-codec/tests/job_util.rs b/events-codec/tests/job_util.rs
@@ -3,7 +3,7 @@ use radroots_events_codec::job::error::JobParseError;
use radroots_events_codec::job::util::{
feedback_status_from_tag, feedback_status_tag, job_input_type_from_tag, job_input_type_tag,
parse_amount_tag_sat, parse_bid_tag_sat, parse_bool_encrypted, parse_i_tags, parse_params,
- push_amount_tag_msat, push_bid_tag_msat,
+ push_amount_tag_msat, push_bid_tag_sat,
};
#[test]
@@ -110,22 +110,30 @@ fn push_amount_tag_msat_writes_msat() {
}
#[test]
-fn parse_bid_tag_sat_accepts_msat() {
- let tags = vec![vec!["bid".to_string(), "2000".to_string()]];
+fn parse_bid_tag_sat_accepts_sat() {
+ let tags = vec![vec!["bid".to_string(), "2".to_string()]];
let bid = parse_bid_tag_sat(&tags).unwrap().unwrap();
assert_eq!(bid, 2);
}
#[test]
-fn parse_bid_tag_sat_rejects_non_whole_sats() {
- let tags = vec![vec!["bid".to_string(), "2500".to_string()]];
+fn parse_bid_tag_sat_rejects_non_numeric() {
+ let tags = vec![vec!["bid".to_string(), "not-a-number".to_string()]];
let err = parse_bid_tag_sat(&tags).unwrap_err();
- assert!(matches!(err, JobParseError::NonWholeSats("bid")));
+ assert!(matches!(err, JobParseError::InvalidNumber("bid", _)));
}
#[test]
-fn push_bid_tag_msat_writes_msat() {
+fn parse_bid_tag_sat_rejects_overflow() {
+ let overflow = (u32::MAX as u64) + 1;
+ let tags = vec![vec!["bid".to_string(), overflow.to_string()]];
+ let err = parse_bid_tag_sat(&tags).unwrap_err();
+ assert!(matches!(err, JobParseError::AmountOverflow("bid")));
+}
+
+#[test]
+fn push_bid_tag_sat_writes_sat() {
let mut tags = Vec::new();
- push_bid_tag_msat(&mut tags, 7);
- assert_eq!(tags[0], vec!["bid".to_string(), "7000".to_string()]);
+ push_bid_tag_sat(&mut tags, 7);
+ assert_eq!(tags[0], vec!["bid".to_string(), "7".to_string()]);
}
diff --git a/events-codec/tests/listing.rs b/events-codec/tests/listing.rs
@@ -5,12 +5,14 @@ use radroots_core::{
RadrootsCoreQuantityPrice, RadrootsCoreUnit,
};
use radroots_events::listing::{
- RadrootsListing, RadrootsListingProduct, RadrootsListingQuantity,
+ RadrootsListing, RadrootsListingDiscount, RadrootsListingImage, RadrootsListingImageSize,
+ RadrootsListingLocation, RadrootsListingProduct, RadrootsListingQuantity,
};
use radroots_events::tags::TAG_D;
use radroots_events_codec::error::{EventEncodeError, EventParseError};
use radroots_events_codec::listing::decode::listing_from_event;
use radroots_events_codec::listing::encode::{listing_build_tags, to_wire_parts};
+use std::str::FromStr;
fn sample_listing(d_tag: &str) -> RadrootsListing {
let quantity = RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each);
@@ -47,6 +49,62 @@ fn sample_listing(d_tag: &str) -> RadrootsListing {
}
}
+fn sample_listing_full(d_tag: &str) -> RadrootsListing {
+ let qty_amount = RadrootsCoreDecimal::from_str("1").unwrap();
+ let price_amount = RadrootsCoreDecimal::from_str("24.50").unwrap();
+ let discount_threshold = RadrootsCoreDecimal::from_str("10").unwrap();
+ let discount_amount = RadrootsCoreDecimal::from_str("20").unwrap();
+
+ let quantity = RadrootsCoreQuantity::new(qty_amount, RadrootsCoreUnit::MassLb).with_label("bag");
+ let price_quantity =
+ RadrootsCoreQuantity::new(qty_amount, RadrootsCoreUnit::MassLb).with_label("bag");
+
+ RadrootsListing {
+ d_tag: d_tag.to_string(),
+ product: RadrootsListingProduct {
+ key: "sku".to_string(),
+ title: "Widget".to_string(),
+ category: "Tools".to_string(),
+ summary: Some("Compact widget".to_string()),
+ process: Some("milled".to_string()),
+ lot: Some("lot-1".to_string()),
+ location: Some("Warehouse".to_string()),
+ profile: Some("standard".to_string()),
+ year: Some("2024".to_string()),
+ },
+ quantities: vec![RadrootsListingQuantity {
+ value: quantity,
+ label: None,
+ count: Some(120),
+ }],
+ prices: vec![RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(price_amount, RadrootsCoreCurrency::USD),
+ price_quantity,
+ )],
+ discounts: Some(vec![RadrootsListingDiscount::Quantity {
+ ref_quantity: "bag".to_string(),
+ threshold: RadrootsCoreQuantity::new(discount_threshold, RadrootsCoreUnit::MassLb),
+ value: RadrootsCoreMoney::new(discount_amount, RadrootsCoreCurrency::USD),
+ }]),
+ inventory_available: None,
+ availability: None,
+ delivery_method: None,
+ location: Some(RadrootsListingLocation {
+ primary: "Moyobamba".to_string(),
+ city: Some("Moyobamba".to_string()),
+ region: Some("San Martin".to_string()),
+ country: Some("PE".to_string()),
+ lat: Some(-6.0346),
+ lng: Some(-76.9714),
+ geohash: None,
+ }),
+ images: Some(vec![RadrootsListingImage {
+ url: "http://example.com/widget.jpg".to_string(),
+ size: Some(RadrootsListingImageSize { w: 1200, h: 800 }),
+ }]),
+ }
+}
+
#[test]
fn listing_build_tags_requires_d_tag() {
let listing = sample_listing("");
@@ -105,3 +163,85 @@ fn listing_from_event_rejects_wrong_kind() {
}
));
}
+
+#[test]
+fn listing_build_tags_includes_listing_fields() {
+ let listing = sample_listing_full("listing-1");
+ let tags = listing_build_tags(&listing).unwrap();
+
+ assert!(tags.iter().any(|t| {
+ t.get(0).map(|s| s.as_str()) == Some(TAG_D)
+ && 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("key")
+ && t.get(1).map(|s| s.as_str()) == Some("sku")
+ }));
+ assert!(tags.iter().any(|t| {
+ t.get(0).map(|s| s.as_str()) == Some("title")
+ && t.get(1).map(|s| s.as_str()) == Some("Widget")
+ }));
+
+ let qty_tag = tags
+ .iter()
+ .find(|t| t.get(0).map(|s| s.as_str()) == Some("quantity"))
+ .expect("quantity tag");
+ assert_eq!(qty_tag.get(2).map(|s| s.as_str()), Some("lb"));
+ assert_eq!(qty_tag.get(3).map(|s| s.as_str()), Some("bag"));
+ assert_eq!(qty_tag.get(4).map(|s| s.as_str()), Some("120"));
+
+ let price_tag = tags
+ .iter()
+ .find(|t| t.get(0).map(|s| s.as_str()) == Some("price"))
+ .expect("price tag");
+ assert_eq!(price_tag.get(2).map(|s| s.as_str()), Some("usd"));
+ assert_eq!(price_tag.get(4).map(|s| s.as_str()), Some("lb"));
+ assert_eq!(price_tag.get(5).map(|s| s.as_str()), Some("bag"));
+
+ let discount_tag = tags
+ .iter()
+ .find(|t| t.get(0).map(|s| s.as_str()) == Some("price-discount-quantity"))
+ .expect("discount tag");
+ assert!(discount_tag
+ .get(1)
+ .map(|s| s.contains("\"ref_quantity\":\"bag\""))
+ .unwrap_or(false));
+
+ assert!(tags.iter().any(|t| {
+ t.get(0).map(|s| s.as_str()) == Some("location")
+ && t.get(1).map(|s| s.as_str()) == Some("Moyobamba")
+ }));
+
+ let g_tags: Vec<&Vec<String>> = tags
+ .iter()
+ .filter(|t| t.get(0).map(|s| s.as_str()) == Some("g"))
+ .collect();
+ assert!(!g_tags.is_empty());
+ let full_len = g_tags[0][1].len();
+ assert_eq!(g_tags.len(), full_len);
+ for (idx, tag) in g_tags.iter().enumerate() {
+ assert_eq!(tag[1].len(), full_len - idx);
+ }
+ assert!(tags.iter().any(|t| {
+ t.get(0).map(|s| s.as_str()) == Some("L")
+ && t.get(1).map(|s| s.as_str()) == Some("dd.lat")
+ }));
+ assert!(tags.iter().any(|t| {
+ t.get(0).map(|s| s.as_str()) == Some("L")
+ && t.get(1).map(|s| s.as_str()) == Some("dd.lon")
+ }));
+ assert!(tags.iter().any(|t| {
+ t.get(0).map(|s| s.as_str()) == Some("l")
+ && t.get(2).map(|s| s.as_str()) == Some("dd.lat")
+ }));
+ assert!(tags.iter().any(|t| {
+ t.get(0).map(|s| s.as_str()) == Some("l")
+ && t.get(2).map(|s| s.as_str()) == Some("dd.lon")
+ }));
+
+ assert!(tags.iter().any(|t| {
+ t.get(0).map(|s| s.as_str()) == Some("image")
+ && t.get(1).map(|s| s.as_str()) == Some("http://example.com/widget.jpg")
+ && t.get(2).map(|s| s.as_str()) == Some("1200x800")
+ }));
+}
diff --git a/events-codec/tests/reaction.rs b/events-codec/tests/reaction.rs
@@ -4,7 +4,7 @@ use radroots_events::reaction::RadrootsReaction;
use radroots_events::tags::TAG_E_ROOT;
use radroots_events_codec::error::{EventEncodeError, EventParseError};
-use radroots_events_codec::event_ref::build_event_ref_tag;
+use radroots_events_codec::event_ref::{build_event_ref_tag, push_nip10_ref_tags};
use radroots_events_codec::reaction::decode::reaction_from_tags;
use radroots_events_codec::reaction::encode::{reaction_build_tags, to_wire_parts};
@@ -40,11 +40,33 @@ fn reaction_to_wire_parts_requires_content() {
fn reaction_from_tags_requires_root_tag() {
let tags = vec![vec!["p".to_string(), "x".to_string()]];
let err = reaction_from_tags(7, &tags, "+").unwrap_err();
- assert!(matches!(err, EventParseError::MissingTag(TAG_E_ROOT)));
+ assert!(matches!(err, EventParseError::MissingTag("e")));
}
#[test]
fn reaction_roundtrip_from_tags() {
+ let root = common::event_ref_with_d(
+ "root",
+ "author",
+ 1,
+ "note-1",
+ Some(vec!["wss://relay".to_string()]),
+ );
+ let mut tags = Vec::new();
+ push_nip10_ref_tags(&mut tags, &root, "e", "p", "k", "a");
+
+ let reaction = reaction_from_tags(7, &tags, "+").unwrap();
+
+ assert_eq!(reaction.root.id, root.id);
+ assert_eq!(reaction.root.author, root.author);
+ assert_eq!(reaction.root.kind, root.kind);
+ assert_eq!(reaction.root.d_tag, root.d_tag);
+ assert_eq!(reaction.root.relays, root.relays);
+ assert_eq!(reaction.content, "+");
+}
+
+#[test]
+fn reaction_roundtrip_from_legacy_tags() {
let root = common::event_ref("root", "author", 1);
let tags = vec![build_event_ref_tag(TAG_E_ROOT, &root)];
diff --git a/events/bindings/ts/src/constants.ts b/events/bindings/ts/src/constants.ts
@@ -0,0 +1,3 @@
+import type { RadrootsListingProductTagKeys } from "./types.js";
+
+export const RADROOTS_LISTING_PRODUCT_TAG_KEYS: RadrootsListingProductTagKeys = ["key", "title", "category", "summary", "process", "lot", "location", "profile", "year"];
diff --git a/events/bindings/ts/src/index.ts b/events/bindings/ts/src/index.ts
@@ -1,5 +1,5 @@
export * from "./lib.js"
export * from "./schemas.js"
+export * from "./constants.js"
export * from "./types.js"
export * from "./typeshare-types.js"
-
diff --git a/events/bindings/ts/src/types.ts b/events/bindings/ts/src/types.ts
@@ -64,6 +64,8 @@ export type RadrootsListingLocation = { primary: string, city?: string | null, r
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, };
+export type RadrootsListingProductTagKeys = readonly ["key", "title", "category", "summary", "process", "lot", "location", "profile", "year"];
+
export type RadrootsListingQuantity = { value: RadrootsCoreQuantity, label?: string | null, count?: number | null, };
export type RadrootsListingStatus = { "kind": "active" } | { "kind": "sold" } | { "kind": "other", "amount": { value: string, } };
diff --git a/events/src/listing.rs b/events/src/listing.rs
@@ -139,6 +139,29 @@ pub struct RadrootsListingProduct {
pub year: Option<String>,
}
+pub const RADROOTS_LISTING_PRODUCT_TAG_KEYS: [&str; 9] = [
+ "key",
+ "title",
+ "category",
+ "summary",
+ "process",
+ "lot",
+ "location",
+ "profile",
+ "year",
+];
+
+#[cfg_attr(feature = "ts-rs", derive(TS))]
+#[cfg_attr(
+ feature = "ts-rs",
+ ts(
+ export,
+ export_to = "types.ts",
+ type = "readonly [\"key\", \"title\", \"category\", \"summary\", \"process\", \"lot\", \"location\", \"profile\", \"year\"]"
+ )
+)]
+pub struct RadrootsListingProductTagKeys;
+
#[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))]
@@ -226,3 +249,37 @@ pub struct RadrootsListingImageSize {
pub w: u32,
pub h: u32,
}
+
+#[cfg(all(test, feature = "ts-rs", feature = "std"))]
+mod constants_tests {
+ use super::RADROOTS_LISTING_PRODUCT_TAG_KEYS;
+ use std::{env, fs, path::Path};
+
+ fn listing_product_tag_keys_literal() -> String {
+ let mut out = String::from("[");
+ for (idx, key) in RADROOTS_LISTING_PRODUCT_TAG_KEYS.iter().enumerate() {
+ if idx > 0 {
+ out.push_str(", ");
+ }
+ out.push('"');
+ out.push_str(key);
+ out.push('"');
+ }
+ out.push(']');
+ out
+ }
+
+ #[test]
+ fn export_listing_product_tag_keys_const() {
+ let out_dir = env::var("TS_RS_EXPORT_DIR").unwrap_or_else(|_| "./bindings".to_string());
+ let path = Path::new(&out_dir).join("constants.ts");
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).expect("create ts export dir");
+ }
+ let keys = listing_product_tag_keys_literal();
+ let content = format!(
+ "import type {{ RadrootsListingProductTagKeys }} from \"./types.js\";\n\nexport const RADROOTS_LISTING_PRODUCT_TAG_KEYS: RadrootsListingProductTagKeys = {keys};\n"
+ );
+ fs::write(&path, content).expect("write constants");
+ }
+}
diff --git a/trade/src/listing/codec.rs b/trade/src/listing/codec.rs
@@ -10,6 +10,8 @@ use radroots_events::listing::{
RadrootsListingProduct, RadrootsListingQuantity, RadrootsListingStatus,
};
use radroots_events::tags::TAG_D;
+use radroots_events_codec::error::EventEncodeError;
+use radroots_events_codec::listing::tags::listing_tags;
#[cfg(feature = "ts-rs")]
use ts_rs::TS;
@@ -113,68 +115,7 @@ pub fn listing_from_event_parts(
}
pub fn listing_tags_build(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, TradeListingParseError> {
- let d_tag = listing.d_tag.trim();
- if d_tag.is_empty() {
- return Err(TradeListingParseError::MissingTag(TAG_D.to_string()));
- }
-
- let mut tags: Vec<Vec<String>> = Vec::new();
- tags.push(vec![TAG_D.to_string(), d_tag.to_string()]);
-
- let product = &listing.product;
- push_tag_value(&mut tags, "key", &product.key);
- push_tag_value(&mut tags, "title", &product.title);
- push_tag_value(&mut tags, "category", &product.category);
- if let Some(summary) = &product.summary {
- push_tag_value(&mut tags, "summary", summary);
- }
- if let Some(process) = &product.process {
- push_tag_value(&mut tags, "process", process);
- }
- if let Some(lot) = &product.lot {
- push_tag_value(&mut tags, "lot", lot);
- }
- if let Some(profile) = &product.profile {
- push_tag_value(&mut tags, "profile", profile);
- }
- if let Some(year) = &product.year {
- push_tag_value(&mut tags, "year", year);
- }
-
- for quantity in &listing.quantities {
- let mut tag = Vec::with_capacity(5);
- tag.push(TAG_QUANTITY.to_string());
- tag.push(quantity.value.amount.to_string());
- tag.push(quantity.value.unit.code().to_string());
- if let Some(label) = quantity.label.as_ref().and_then(|v| clean_value(v)) {
- tag.push(label);
- }
- if let Some(count) = quantity.count {
- tag.push(count.to_string());
- }
- tags.push(tag);
- }
-
- for price in &listing.prices {
- let mut tag = Vec::with_capacity(6);
- tag.push(TAG_PRICE.to_string());
- tag.push(price.amount.amount.to_string());
- tag.push(price.amount.currency.to_string().to_ascii_lowercase());
- tag.push(price.quantity.amount.to_string());
- tag.push(price.quantity.unit.code().to_string());
- if let Some(label) = price.quantity.label.as_ref().and_then(|v| clean_value(v)) {
- tag.push(label);
- }
- tags.push(tag);
- }
-
- if let Some(discounts) = &listing.discounts {
- for discount in discounts {
- let (kind, payload) = discount_to_tag_parts(discount)?;
- tags.push(vec![format!("{TAG_PRICE_DISCOUNT_PREFIX}{kind}"), payload]);
- }
- }
-
+ let mut tags = listing_tags(listing).map_err(map_listing_tags_error)?;
if let Some(inventory) = &listing.inventory_available {
tags.push(vec![TAG_INVENTORY.to_string(), inventory.to_string()]);
}
@@ -247,6 +188,16 @@ pub fn listing_tags_build(listing: &RadrootsListing) -> Result<Vec<Vec<String>>,
Ok(tags)
}
+fn map_listing_tags_error(err: EventEncodeError) -> TradeListingParseError {
+ match err {
+ EventEncodeError::EmptyRequiredField(field) => {
+ TradeListingParseError::MissingTag(field.to_string())
+ }
+ EventEncodeError::Json => TradeListingParseError::InvalidJson("discount".to_string()),
+ EventEncodeError::InvalidKind(_) => TradeListingParseError::InvalidTag("kind".to_string()),
+ }
+}
+
fn listing_from_tags(
tags: &[Vec<String>],
d_tag: String,
@@ -458,12 +409,6 @@ fn listing_from_tags(
})
}
-fn push_tag_value(tags: &mut Vec<Vec<String>>, key: &str, value: &str) {
- if let Some(cleaned) = clean_value(value) {
- tags.push(vec![key.to_string(), cleaned]);
- }
-}
-
fn clean_value(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
@@ -514,45 +459,6 @@ fn parse_image_size(value: &str) -> Option<RadrootsListingImageSize> {
Some(RadrootsListingImageSize { w, h })
}
-fn discount_to_tag_parts(
- discount: &RadrootsListingDiscount,
-) -> Result<(&'static str, String), TradeListingParseError> {
- #[cfg(feature = "serde_json")]
- {
- let (kind, payload) = match discount {
- RadrootsListingDiscount::Quantity {
- ref_quantity,
- threshold,
- value,
- } => ("quantity", serde_json::to_string(&QuantityDiscountPayload {
- ref_quantity: ref_quantity.clone(),
- threshold: threshold.clone(),
- value: value.clone(),
- })),
- RadrootsListingDiscount::Mass { threshold, value } => ("mass", serde_json::to_string(&MassDiscountPayload {
- threshold: threshold.clone(),
- value: value.clone(),
- })),
- RadrootsListingDiscount::Subtotal { threshold, value } => ("subtotal", serde_json::to_string(&SubtotalDiscountPayload {
- threshold: threshold.clone(),
- value: value.clone(),
- })),
- RadrootsListingDiscount::Total { total_min, value } => ("total", serde_json::to_string(&TotalDiscountPayload {
- total_min: total_min.clone(),
- value: value.clone(),
- })),
- };
- let payload =
- payload.map_err(|_| TradeListingParseError::InvalidJson("discount".to_string()))?;
- return Ok((kind, payload));
- }
- #[cfg(not(feature = "serde_json"))]
- {
- let _ = discount;
- Err(TradeListingParseError::InvalidJson("discount".to_string()))
- }
-}
-
fn parse_discount(
kind: &str,
payload: &str,