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