commit da29d88e8f0b8316fa91287d6a7ef184a57459c0
parent 257730a483821b80e3cd1739ffefc7c10011141e
Author: triesap <tyson@radroots.org>
Date: Sun, 12 Apr 2026 04:21:43 +0000
trade: move shared workflow validation into crate
Diffstat:
8 files changed, 486 insertions(+), 0 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2786,6 +2786,7 @@ dependencies = [
"radroots_events_codec",
"serde",
"serde_json",
+ "thiserror 1.0.69",
"ts-rs",
]
diff --git a/crates/trade/Cargo.toml b/crates/trade/Cargo.toml
@@ -36,4 +36,5 @@ serde = { workspace = true, default-features = false, features = [
serde_json = { workspace = true, default-features = false, features = [
"alloc",
], optional = true }
+thiserror = { workspace = true }
ts-rs = { workspace = true, optional = true }
diff --git a/crates/trade/src/lib.rs b/crates/trade/src/lib.rs
@@ -3,4 +3,6 @@
extern crate alloc;
pub mod listing;
+pub mod order;
pub mod prelude;
+pub mod public_trade;
diff --git a/crates/trade/src/listing/mod.rs b/crates/trade/src/listing/mod.rs
@@ -4,6 +4,7 @@ pub mod model;
pub mod overlay;
pub mod price_ext;
pub mod projection;
+pub mod publish;
pub mod validation;
pub(crate) use self::contract as dvm;
diff --git a/crates/trade/src/listing/publish.rs b/crates/trade/src/listing/publish.rs
@@ -0,0 +1,165 @@
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::string::String;
+
+use radroots_events::RadrootsNostrEvent;
+use radroots_events::kinds::{KIND_LISTING, KIND_LISTING_DRAFT, is_listing_kind};
+use radroots_events::listing::RadrootsListing;
+use radroots_events_codec::listing::encode::to_wire_parts_with_kind;
+use thiserror::Error;
+
+use crate::listing::validation::{RadrootsTradeListing, validate_listing_event};
+
+#[derive(Debug, Error)]
+pub enum RadrootsTradeListingPublishError {
+ #[error("listing kind must be {KIND_LISTING} or {KIND_LISTING_DRAFT}")]
+ InvalidKind,
+ #[error("invalid listing contract: {0}")]
+ InvalidContract(String),
+}
+
+pub fn resolve_listing_kind(kind: Option<u32>) -> Result<u32, RadrootsTradeListingPublishError> {
+ let kind = kind.unwrap_or(KIND_LISTING);
+ if !is_listing_kind(kind) {
+ return Err(RadrootsTradeListingPublishError::InvalidKind);
+ }
+ Ok(kind)
+}
+
+pub fn canonicalize_listing_for_seller(
+ mut listing: RadrootsListing,
+ seller_pubkey: &str,
+) -> RadrootsListing {
+ if listing.farm.pubkey.trim().is_empty() {
+ listing.farm.pubkey = seller_pubkey.to_string();
+ }
+ listing
+}
+
+pub fn validate_listing_for_seller(
+ listing: RadrootsListing,
+ seller_pubkey: &str,
+ kind: u32,
+) -> Result<RadrootsTradeListing, RadrootsTradeListingPublishError> {
+ let parts = to_wire_parts_with_kind(&listing, kind)
+ .map_err(|error| RadrootsTradeListingPublishError::InvalidContract(error.to_string()))?;
+ let canonical = RadrootsNostrEvent {
+ id: String::new(),
+ author: seller_pubkey.to_string(),
+ created_at: 0,
+ kind: parts.kind,
+ tags: parts.tags,
+ content: parts.content,
+ sig: String::new(),
+ };
+ validate_listing_event(&canonical)
+ .map_err(|error| RadrootsTradeListingPublishError::InvalidContract(error.to_string()))
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+ };
+ use radroots_events::listing::{
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
+ RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation,
+ RadrootsListingProduct,
+ };
+
+ use super::{
+ canonicalize_listing_for_seller, resolve_listing_kind, validate_listing_for_seller,
+ };
+
+ fn base_listing() -> RadrootsListing {
+ RadrootsListing {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAg".into(),
+ farm: RadrootsListingFarmRef {
+ pubkey: String::new(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ },
+ product: RadrootsListingProduct {
+ key: "coffee".into(),
+ title: "Coffee".into(),
+ category: "coffee".into(),
+ summary: Some("Single origin coffee".into()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: "bin-1".into(),
+ bins: vec![RadrootsListingBin {
+ bin_id: "bin-1".into(),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1000u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice {
+ amount: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(20u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ },
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
+ availability: Some(RadrootsListingAvailability::Status {
+ status: radroots_events::listing::RadrootsListingStatus::Active,
+ }),
+ delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
+ location: Some(RadrootsListingLocation {
+ primary: "Farm".into(),
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: None,
+ }),
+ images: None,
+ }
+ }
+
+ #[test]
+ fn resolve_listing_kind_accepts_supported_kinds() {
+ assert_eq!(
+ resolve_listing_kind(None).unwrap(),
+ radroots_events::kinds::KIND_LISTING
+ );
+ assert_eq!(
+ resolve_listing_kind(Some(radroots_events::kinds::KIND_LISTING_DRAFT)).unwrap(),
+ radroots_events::kinds::KIND_LISTING_DRAFT
+ );
+ }
+
+ #[test]
+ fn canonicalize_listing_sets_missing_farm_pubkey() {
+ let listing = canonicalize_listing_for_seller(base_listing(), "seller");
+ assert_eq!(listing.farm.pubkey, "seller");
+ }
+
+ #[test]
+ fn validate_listing_for_seller_returns_listing_addr() {
+ let listing = canonicalize_listing_for_seller(base_listing(), "seller");
+ let validated =
+ validate_listing_for_seller(listing, "seller", radroots_events::kinds::KIND_LISTING)
+ .expect("validated listing");
+ assert_eq!(validated.seller_pubkey, "seller");
+ assert!(validated.listing_addr.contains(":seller:"));
+ }
+}
diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs
@@ -0,0 +1,132 @@
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::string::{String, ToString};
+
+use radroots_events::kinds::KIND_LISTING;
+use radroots_events::trade::RadrootsTradeOrder as TradeOrder;
+use radroots_events_codec::trade::RadrootsTradeListingAddress as TradeListingAddress;
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum RadrootsTradeOrderCanonicalizationError {
+ #[error("{0} cannot be empty")]
+ EmptyField(&'static str),
+ #[error("invalid listing_addr: {0}")]
+ InvalidListingAddress(String),
+ #[error("listing_addr must reference a public NIP-99 listing")]
+ InvalidListingKind,
+ #[error("buyer_pubkey must match the requested signer identity")]
+ InvalidBuyerSigner,
+ #[error("seller_pubkey must match listing_addr seller")]
+ InvalidSellerListing,
+ #[error("items must contain at least one item")]
+ MissingItems,
+ #[error("items[{index}].bin_count must be greater than zero")]
+ InvalidBinCount { index: usize },
+}
+
+pub fn canonicalize_order_request_for_signer(
+ mut order: TradeOrder,
+ signer_pubkey: &str,
+) -> Result<TradeOrder, RadrootsTradeOrderCanonicalizationError> {
+ let order_id = normalized_required_string(core::mem::take(&mut order.order_id), "order_id")?;
+ let listing_addr_raw =
+ normalized_required_string(core::mem::take(&mut order.listing_addr), "listing_addr")?;
+ let listing_addr = TradeListingAddress::parse(&listing_addr_raw).map_err(|error| {
+ RadrootsTradeOrderCanonicalizationError::InvalidListingAddress(error.to_string())
+ })?;
+ if u32::from(listing_addr.kind) != KIND_LISTING {
+ return Err(RadrootsTradeOrderCanonicalizationError::InvalidListingKind);
+ }
+
+ let buyer_pubkey = if order.buyer_pubkey.trim().is_empty() {
+ signer_pubkey.to_string()
+ } else {
+ normalized_required_string(core::mem::take(&mut order.buyer_pubkey), "buyer_pubkey")?
+ };
+ if buyer_pubkey != signer_pubkey {
+ return Err(RadrootsTradeOrderCanonicalizationError::InvalidBuyerSigner);
+ }
+
+ let seller_pubkey = if order.seller_pubkey.trim().is_empty() {
+ listing_addr.seller_pubkey.clone()
+ } else {
+ normalized_required_string(core::mem::take(&mut order.seller_pubkey), "seller_pubkey")?
+ };
+ if seller_pubkey != listing_addr.seller_pubkey {
+ return Err(RadrootsTradeOrderCanonicalizationError::InvalidSellerListing);
+ }
+
+ if order.items.is_empty() {
+ return Err(RadrootsTradeOrderCanonicalizationError::MissingItems);
+ }
+ for (index, item) in order.items.iter_mut().enumerate() {
+ item.bin_id = normalized_required_string(item.bin_id.clone(), "bin_id")?;
+ if item.bin_count == 0 {
+ return Err(RadrootsTradeOrderCanonicalizationError::InvalidBinCount { index });
+ }
+ }
+
+ order.order_id = order_id;
+ order.listing_addr = listing_addr.as_str();
+ order.buyer_pubkey = buyer_pubkey;
+ order.seller_pubkey = seller_pubkey;
+ if order.discounts.as_ref().is_some_and(Vec::is_empty) {
+ order.discounts = None;
+ }
+ Ok(order)
+}
+
+fn normalized_required_string(
+ value: String,
+ field: &'static str,
+) -> Result<String, RadrootsTradeOrderCanonicalizationError> {
+ let value = value.trim().to_string();
+ if value.is_empty() {
+ return Err(RadrootsTradeOrderCanonicalizationError::EmptyField(field));
+ }
+ Ok(value)
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_events::kinds::KIND_LISTING;
+ use radroots_events::trade::{RadrootsTradeOrder as TradeOrder, RadrootsTradeOrderItem};
+
+ use super::canonicalize_order_request_for_signer;
+
+ fn base_order(buyer_pubkey: &str, seller_pubkey: &str) -> TradeOrder {
+ TradeOrder {
+ order_id: "order-1".to_string(),
+ listing_addr: format!(
+ "{KIND_LISTING}:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"
+ ),
+ buyer_pubkey: buyer_pubkey.to_string(),
+ seller_pubkey: seller_pubkey.to_string(),
+ items: vec![RadrootsTradeOrderItem {
+ bin_id: "bin-1".to_string(),
+ bin_count: 1,
+ }],
+ discounts: None,
+ }
+ }
+
+ #[test]
+ fn canonicalize_order_request_sets_missing_pubkeys() {
+ let order = canonicalize_order_request_for_signer(
+ base_order("", ""),
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ )
+ .expect("canonical order");
+
+ assert_eq!(
+ order.buyer_pubkey,
+ "1111111111111111111111111111111111111111111111111111111111111111"
+ );
+ assert_eq!(
+ order.seller_pubkey,
+ "1111111111111111111111111111111111111111111111111111111111111111"
+ );
+ }
+}
diff --git a/crates/trade/src/prelude.rs b/crates/trade/src/prelude.rs
@@ -1 +1,3 @@
pub use crate::listing::*;
+pub use crate::order::*;
+pub use crate::public_trade::*;
diff --git a/crates/trade/src/public_trade.rs b/crates/trade/src/public_trade.rs
@@ -0,0 +1,182 @@
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::string::{String, ToString};
+
+use radroots_events::kinds::KIND_LISTING;
+use radroots_events::trade::RadrootsTradeMessageType as TradeListingMessageType;
+use radroots_events_codec::trade::RadrootsTradeListingAddress as TradeListingAddress;
+use thiserror::Error;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct CanonicalPublicTradeContext {
+ pub listing_addr: String,
+ pub order_id: String,
+ pub counterparty_pubkey: String,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum ExpectedPublicTradeAuthor {
+ Buyer,
+ Seller,
+ Either,
+}
+
+#[derive(Debug, Error)]
+pub enum RadrootsPublicTradeCanonicalizationError {
+ #[error("{0} cannot be empty")]
+ EmptyField(&'static str),
+ #[error("invalid listing_addr: {0}")]
+ InvalidListingAddress(String),
+ #[error("listing_addr must reference a public NIP-99 listing")]
+ InvalidListingKind,
+ #[error("counterparty_pubkey must not match the requested signer identity")]
+ DuplicateCounterparty,
+ #[error("{0}")]
+ InvalidAuthor(String),
+}
+
+pub fn canonicalize_public_trade_context(
+ listing_addr: String,
+ order_id: String,
+ counterparty_pubkey: String,
+ signer_pubkey: &str,
+ message_type: TradeListingMessageType,
+) -> Result<CanonicalPublicTradeContext, RadrootsPublicTradeCanonicalizationError> {
+ let listing_addr = normalized_required_string(listing_addr, "listing_addr")?;
+ let parsed_listing_addr = TradeListingAddress::parse(&listing_addr).map_err(|error| {
+ RadrootsPublicTradeCanonicalizationError::InvalidListingAddress(error.to_string())
+ })?;
+ if u32::from(parsed_listing_addr.kind) != KIND_LISTING {
+ return Err(RadrootsPublicTradeCanonicalizationError::InvalidListingKind);
+ }
+
+ let order_id = normalized_required_string(order_id, "order_id")?;
+ let counterparty_pubkey =
+ normalized_required_string(counterparty_pubkey, "counterparty_pubkey")?;
+ if counterparty_pubkey == signer_pubkey {
+ return Err(RadrootsPublicTradeCanonicalizationError::DuplicateCounterparty);
+ }
+
+ validate_expected_author(
+ &parsed_listing_addr,
+ message_type,
+ signer_pubkey,
+ &counterparty_pubkey,
+ )?;
+
+ Ok(CanonicalPublicTradeContext {
+ listing_addr,
+ order_id,
+ counterparty_pubkey,
+ })
+}
+
+fn validate_expected_author(
+ listing_addr: &TradeListingAddress,
+ message_type: TradeListingMessageType,
+ signer_pubkey: &str,
+ counterparty_pubkey: &str,
+) -> Result<(), RadrootsPublicTradeCanonicalizationError> {
+ match expected_author(message_type) {
+ ExpectedPublicTradeAuthor::Seller => {
+ if signer_pubkey != listing_addr.seller_pubkey {
+ return Err(RadrootsPublicTradeCanonicalizationError::InvalidAuthor(
+ format!("{message_type:?} must be authored by the listing seller"),
+ ));
+ }
+ if counterparty_pubkey == listing_addr.seller_pubkey {
+ return Err(RadrootsPublicTradeCanonicalizationError::InvalidAuthor(
+ format!("{message_type:?} counterparty must not be the listing seller"),
+ ));
+ }
+ }
+ ExpectedPublicTradeAuthor::Buyer => {
+ if signer_pubkey == listing_addr.seller_pubkey {
+ return Err(RadrootsPublicTradeCanonicalizationError::InvalidAuthor(
+ format!("{message_type:?} must be authored by the listing buyer"),
+ ));
+ }
+ if counterparty_pubkey != listing_addr.seller_pubkey {
+ return Err(RadrootsPublicTradeCanonicalizationError::InvalidAuthor(
+ format!("{message_type:?} counterparty must be the listing seller"),
+ ));
+ }
+ }
+ ExpectedPublicTradeAuthor::Either => {}
+ }
+ Ok(())
+}
+
+fn expected_author(message_type: TradeListingMessageType) -> ExpectedPublicTradeAuthor {
+ use TradeListingMessageType as MessageType;
+
+ match message_type {
+ MessageType::OrderResponse
+ | MessageType::OrderRevision
+ | MessageType::OrderRevisionAccept
+ | MessageType::OrderRevisionDecline
+ | MessageType::Answer
+ | MessageType::DiscountOffer
+ | MessageType::DiscountAccept
+ | MessageType::DiscountDecline
+ | MessageType::FulfillmentUpdate => ExpectedPublicTradeAuthor::Seller,
+ MessageType::Question
+ | MessageType::DiscountRequest
+ | MessageType::Cancel
+ | MessageType::Receipt => ExpectedPublicTradeAuthor::Buyer,
+ MessageType::OrderRequest
+ | MessageType::ListingValidateRequest
+ | MessageType::ListingValidateResult => ExpectedPublicTradeAuthor::Either,
+ }
+}
+
+fn normalized_required_string(
+ value: String,
+ field: &'static str,
+) -> Result<String, RadrootsPublicTradeCanonicalizationError> {
+ let value = value.trim().to_string();
+ if value.is_empty() {
+ return Err(RadrootsPublicTradeCanonicalizationError::EmptyField(field));
+ }
+ Ok(value)
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_events::kinds::KIND_LISTING;
+
+ use super::canonicalize_public_trade_context;
+
+ #[test]
+ fn canonicalize_public_trade_context_accepts_seller_authored_message() {
+ let context = canonicalize_public_trade_context(
+ format!(
+ "{KIND_LISTING}:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"
+ ),
+ "order-1".to_string(),
+ "2222222222222222222222222222222222222222222222222222222222222222".to_string(),
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ super::TradeListingMessageType::OrderResponse,
+ )
+ .expect("canonical public trade context");
+
+ assert_eq!(context.order_id, "order-1");
+ }
+
+ #[test]
+ fn canonicalize_public_trade_context_rejects_wrong_seller_role() {
+ let err = canonicalize_public_trade_context(
+ format!(
+ "{KIND_LISTING}:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"
+ ),
+ "order-1".to_string(),
+ "3333333333333333333333333333333333333333333333333333333333333333".to_string(),
+ "2222222222222222222222222222222222222222222222222222222222222222",
+ super::TradeListingMessageType::OrderResponse,
+ )
+ .expect_err("invalid seller role");
+
+ assert!(err.to_string().contains("listing seller"));
+ }
+}