lib

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

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:
MCargo.toml | 2+-
Mevents-codec/src/comment/decode.rs | 21++++++++++++++-------
Mevents-codec/src/comment/encode.rs | 19++++++++++++++-----
Mevents-codec/src/event_ref.rs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mevents-codec/src/follow/decode.rs | 15+++++++++++++--
Mevents-codec/src/follow/encode.rs | 13+++++++++----
Mevents-codec/src/job/feedback/encode.rs | 8++++----
Mevents-codec/src/job/request/encode.rs | 4++--
Mevents-codec/src/job/util.rs | 13++++---------
Mevents-codec/src/lib.rs | 4+++-
Mevents-codec/src/listing/encode.rs | 21++++++++++-----------
Mevents-codec/src/listing/mod.rs | 1+
Aevents-codec/src/listing/tags.rs | 459+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mevents-codec/src/reaction/decode.rs | 12++++++++----
Mevents-codec/src/reaction/encode.rs | 12++++++++----
Aevents-codec/src/tag_builders.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mevents-codec/tests/comment.rs | 48++++++++++++++++++++++++++++++++++++++----------
Mevents-codec/tests/follow.rs | 16+++++++++++++++-
Mevents-codec/tests/job_util.rs | 26+++++++++++++++++---------
Mevents-codec/tests/listing.rs | 142++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mevents-codec/tests/reaction.rs | 26++++++++++++++++++++++++--
Aevents/bindings/ts/src/constants.ts | 3+++
Mevents/bindings/ts/src/index.ts | 2+-
Mevents/bindings/ts/src/types.ts | 2++
Mevents/src/listing.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtrade/src/listing/codec.rs | 120+++++++++----------------------------------------------------------------------
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,