lib

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

commit a6f662f4d42e1591c6cfc3fdc614c20df5c2fdd7
parent 2498fcd076cc0d64f6fc251955bde03340bf8359
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 14:35:00 -0700

trade: expose listing address parsing

- add listing and public-listing parsers with seller and listing id parts
- accept draft listing addresses only at the generic listing boundary
- replace order-local coordinate splitting with the public trade helper
- verify radroots_trade check and listing/order tests with all features

Diffstat:
Mcrates/trade/src/listing/mod.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/trade/src/order.rs | 60++++++++++++++++++++++++++++++------------------------------
2 files changed, 182 insertions(+), 33 deletions(-)

diff --git a/crates/trade/src/listing/mod.rs b/crates/trade/src/listing/mod.rs @@ -5,7 +5,16 @@ pub mod mutation; pub mod price_ext; pub mod validation; -use radroots_events::{RadrootsNostrEvent, kinds::is_listing_kind, listing::RadrootsListing}; +use radroots_events::{ + RadrootsNostrEvent, + ids::{ + RadrootsAddressableCoordinateParts, RadrootsDTag, RadrootsIdParseError, + RadrootsListingAddress, RadrootsPublicKey, + }, + kinds::{KIND_LISTING, is_listing_kind}, + listing::RadrootsListing, +}; +use thiserror::Error; pub use self::draft::{ RadrootsCanonicalListingDraft, RadrootsListingDraftDocumentV1, RadrootsListingDraftError, @@ -18,6 +27,93 @@ pub use self::mutation::{ }; pub use radroots_events::order::RadrootsListingParseError as ListingParseError; +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum RadrootsListingAddressError { + #[error("invalid listing address: {0}")] + InvalidAddress(RadrootsIdParseError), + #[error("listing address must reference a listing kind")] + InvalidKind { actual: u32 }, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum RadrootsPublicListingAddressError { + #[error("invalid listing address: {0}")] + InvalidAddress(RadrootsIdParseError), + #[error("listing address must reference a listing kind")] + InvalidListingKind { actual: u32 }, + #[error("listing address must reference a public NIP-99 listing")] + InvalidKind { actual: u32 }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsListingAddressParts { + pub address: RadrootsListingAddress, + pub kind: u32, + pub seller_pubkey: RadrootsPublicKey, + pub listing_id: RadrootsDTag, +} + +impl RadrootsListingAddressParts { + pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsListingAddressError> { + parse_listing_address(value) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsPublicListingAddress { + pub address: RadrootsListingAddress, + pub kind: u32, + pub seller_pubkey: RadrootsPublicKey, + pub listing_id: RadrootsDTag, +} + +impl RadrootsPublicListingAddress { + pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsPublicListingAddressError> { + parse_public_listing_address(value) + } +} + +pub fn parse_listing_address( + value: impl AsRef<str>, +) -> Result<RadrootsListingAddressParts, RadrootsListingAddressError> { + let value = value.as_ref(); + let address = RadrootsListingAddress::parse(value) + .map_err(RadrootsListingAddressError::InvalidAddress)?; + let parts = RadrootsAddressableCoordinateParts::parse(address.as_str()) + .map_err(RadrootsListingAddressError::InvalidAddress)?; + if !is_listing_kind(parts.kind) { + return Err(RadrootsListingAddressError::InvalidKind { actual: parts.kind }); + } + Ok(RadrootsListingAddressParts { + address, + kind: parts.kind, + seller_pubkey: parts.pubkey, + listing_id: parts.d_tag, + }) +} + +pub fn parse_public_listing_address( + value: impl AsRef<str>, +) -> Result<RadrootsPublicListingAddress, RadrootsPublicListingAddressError> { + let parts = parse_listing_address(value).map_err(|error| match error { + RadrootsListingAddressError::InvalidAddress(error) => { + RadrootsPublicListingAddressError::InvalidAddress(error) + } + RadrootsListingAddressError::InvalidKind { actual } => { + RadrootsPublicListingAddressError::InvalidListingKind { actual } + } + })?; + if parts.kind != KIND_LISTING { + return Err(RadrootsPublicListingAddressError::InvalidKind { actual: parts.kind }); + } + Ok(RadrootsPublicListingAddress { + address: parts.address, + kind: parts.kind, + seller_pubkey: parts.seller_pubkey, + listing_id: parts.listing_id, + }) +} + pub fn parse_listing_event( event: &RadrootsNostrEvent, ) -> Result<RadrootsListing, ListingParseError> { @@ -29,11 +125,18 @@ pub fn parse_listing_event( #[cfg(test)] mod tests { - use super::parse_listing_event; + use super::{ + RadrootsListingAddressError, RadrootsPublicListingAddressError, parse_listing_address, + parse_listing_event, parse_public_listing_address, + }; use radroots_events::{ - RadrootsNostrEvent, kinds::KIND_PROFILE, order::RadrootsListingParseError, + RadrootsNostrEvent, + kinds::{KIND_LISTING, KIND_LISTING_DRAFT, KIND_PROFILE}, + order::RadrootsListingParseError, }; + const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + #[test] fn parse_listing_event_rejects_non_listing_kind() { let event = RadrootsNostrEvent { @@ -51,4 +154,50 @@ mod tests { Err(RadrootsListingParseError::InvalidKind(KIND_PROFILE)) )); } + + #[test] + fn parse_public_listing_address_accepts_public_listing_kind() { + let raw = format!("{KIND_LISTING}:{SELLER}:listing-1"); + let parsed = parse_public_listing_address(&raw).expect("public listing address"); + + assert_eq!(parsed.address.as_str(), raw); + assert_eq!(parsed.kind, KIND_LISTING); + assert_eq!(parsed.seller_pubkey.as_str(), SELLER); + assert_eq!(parsed.listing_id.as_str(), "listing-1"); + } + + #[test] + fn parse_listing_address_accepts_draft_listing_kind() { + let raw = format!("{KIND_LISTING_DRAFT}:{SELLER}:listing-1"); + let parsed = parse_listing_address(&raw).expect("listing address"); + + assert_eq!(parsed.address.as_str(), raw); + assert_eq!(parsed.kind, KIND_LISTING_DRAFT); + assert_eq!(parsed.seller_pubkey.as_str(), SELLER); + assert_eq!(parsed.listing_id.as_str(), "listing-1"); + } + + #[test] + fn parse_public_listing_address_rejects_draft_listing_kind() { + let raw = format!("{KIND_LISTING_DRAFT}:{SELLER}:listing-1"); + + assert!(matches!( + parse_public_listing_address(&raw), + Err(RadrootsPublicListingAddressError::InvalidKind { + actual: KIND_LISTING_DRAFT + }) + )); + } + + #[test] + fn parse_listing_address_rejects_non_listing_kind() { + let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1"); + + assert!(matches!( + parse_listing_address(&raw), + Err(RadrootsListingAddressError::InvalidKind { + actual: KIND_PROFILE + }) + )); + } } diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs @@ -15,7 +15,6 @@ use radroots_events::ids::{ RadrootsEconomicsDigest, RadrootsEventId, RadrootsIdParseError, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, RadrootsOrderQuoteId, RadrootsPublicKey, }; -use radroots_events::kinds::KIND_LISTING; #[cfg(feature = "serde_json")] use radroots_events::kinds::{ KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_FULFILLMENT_UPDATE, @@ -46,6 +45,10 @@ use radroots_events_codec::order::{ use sha2::{Digest, Sha256}; use thiserror::Error; +use crate::listing::{ + RadrootsPublicListingAddress, RadrootsPublicListingAddressError, parse_public_listing_address, +}; + #[derive(Debug, Error)] pub enum RadrootsOrderCanonicalizationError { #[error("{0} cannot be empty")] @@ -3764,36 +3767,17 @@ fn invalid_projection_with_payment( } } -#[derive(Clone, Debug, PartialEq, Eq)] -struct RadrootsPublicListingAddressParts { - address: RadrootsListingAddress, - seller_pubkey: RadrootsPublicKey, -} - fn parse_public_listing_addr( listing_addr_raw: &str, -) -> Result<RadrootsPublicListingAddressParts, RadrootsOrderCanonicalizationError> { - let address = RadrootsListingAddress::parse(listing_addr_raw).map_err(|error| { - RadrootsOrderCanonicalizationError::InvalidListingAddress(error.to_string()) - })?; - let (kind_raw, seller_and_listing) = address.as_str().split_once(':').ok_or_else(|| { - RadrootsOrderCanonicalizationError::InvalidListingAddress(listing_addr_raw.to_string()) - })?; - let (seller_pubkey_raw, _) = seller_and_listing.split_once(':').ok_or_else(|| { - RadrootsOrderCanonicalizationError::InvalidListingAddress(listing_addr_raw.to_string()) - })?; - let kind = kind_raw.parse::<u32>().map_err(|_| { - RadrootsOrderCanonicalizationError::InvalidListingAddress(listing_addr_raw.to_string()) - })?; - if kind != KIND_LISTING { - return Err(RadrootsOrderCanonicalizationError::InvalidListingKind); - } - let seller_pubkey = RadrootsPublicKey::parse(seller_pubkey_raw).map_err(|error| { - RadrootsOrderCanonicalizationError::InvalidListingAddress(error.to_string()) - })?; - Ok(RadrootsPublicListingAddressParts { - address, - seller_pubkey, +) -> Result<RadrootsPublicListingAddress, RadrootsOrderCanonicalizationError> { + parse_public_listing_address(listing_addr_raw).map_err(|error| match error { + RadrootsPublicListingAddressError::InvalidAddress(error) => { + RadrootsOrderCanonicalizationError::InvalidListingAddress(error.to_string()) + } + RadrootsPublicListingAddressError::InvalidListingKind { .. } + | RadrootsPublicListingAddressError::InvalidKind { .. } => { + RadrootsOrderCanonicalizationError::InvalidListingKind + } }) } @@ -3939,7 +3923,7 @@ mod tests { RadrootsEconomicsDigest, RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, RadrootsOrderQuoteId, RadrootsOrderRevisionId, RadrootsPublicKey, }; - use radroots_events::kinds::KIND_LISTING; + use radroots_events::kinds::{KIND_LISTING, KIND_LISTING_DRAFT}; use radroots_events::order::{ RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, RadrootsOrderEconomics, @@ -5280,6 +5264,22 @@ mod tests { } #[test] + fn canonicalize_order_request_rejects_draft_listing_address() { + let mut request = sample_order_request("", ""); + request.listing_addr = RadrootsListingAddress::parse(format!( + "{KIND_LISTING_DRAFT}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg" + )) + .expect("draft listing address"); + + let error = canonicalize_order_request_for_signer(request, BUYER).unwrap_err(); + + assert!(matches!( + error, + RadrootsOrderCanonicalizationError::InvalidListingKind + )); + } + + #[test] fn canonicalize_order_decision_sets_seller_authority_and_commitments() { let decision = canonicalize_order_decision_for_signer(sample_order_decision(""), SELLER).unwrap();