lib

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

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:
MCargo.lock | 1+
Mcrates/trade/Cargo.toml | 1+
Mcrates/trade/src/lib.rs | 2++
Mcrates/trade/src/listing/mod.rs | 1+
Acrates/trade/src/listing/publish.rs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/trade/src/order.rs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/trade/src/prelude.rs | 2++
Acrates/trade/src/public_trade.rs | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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")); + } +}