lib

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

commit fab7ed497fd658df947cd8794fac9b62e80ebb4c
parent 52e6cdecf8690e395ec9f38ec65f3f3a0123bcf1
Author: triesap <tyson@radroots.org>
Date:   Mon, 13 Apr 2026 15:10:20 +0000

sdk: validate public trade publish requests

Diffstat:
Mcrates/sdk/src/adapters/radrootsd.rs | 412+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/client.rs | 24++++++++++--------------
Mcrates/sdk/src/lib.rs | 3++-
Mcrates/sdk/tests/radrootsd.rs | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
4 files changed, 559 insertions(+), 30 deletions(-)

diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs @@ -170,6 +170,128 @@ pub struct SdkRadrootsdPublicTradePublishRequest { pub payload: trade::RadrootsTradeMessagePayload, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkRadrootsdPublicTradeRoute { + listing_addr: String, + order_id: String, + counterparty_pubkey: String, +} + +impl SdkRadrootsdPublicTradeRoute { + pub fn new( + listing_addr: impl Into<String>, + order_id: impl Into<String>, + counterparty_pubkey: impl Into<String>, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + let listing_addr = normalize_required_string(listing_addr, "listing_addr")?; + let order_id = normalize_required_string(order_id, "order_id")?; + let counterparty_pubkey = + normalize_required_string(counterparty_pubkey, "counterparty_pubkey")?; + Ok(Self { + listing_addr, + order_id, + counterparty_pubkey, + }) + } + + fn listing_addr(&self) -> &str { + self.listing_addr.as_str() + } + + fn order_id(&self) -> &str { + self.order_id.as_str() + } + + fn counterparty_pubkey(&self) -> &str { + self.counterparty_pubkey.as_str() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkRadrootsdTradeChain { + root_event_id: String, + prev_event_id: String, +} + +impl SdkRadrootsdTradeChain { + pub fn new( + root_event_id: impl Into<String>, + prev_event_id: impl Into<String>, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + let root_event_id = normalize_required_string(root_event_id, "root_event_id")?; + let prev_event_id = normalize_required_string(prev_event_id, "prev_event_id")?; + Ok(Self { + root_event_id, + prev_event_id, + }) + } + + fn root_event_id(&self) -> &str { + self.root_event_id.as_str() + } + + fn prev_event_id(&self) -> &str { + self.prev_event_id.as_str() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SdkRadrootsdPublicTradePublishValidationError { + UnsupportedPayload(trade::RadrootsTradeMessageType), + MissingListingSnapshot(trade::RadrootsTradeMessageType), + ListingSnapshotRelaysEmpty, + MissingTradeChain(trade::RadrootsTradeMessageType), + InvalidOrderRevisionAcceptPayload, + InvalidOrderRevisionDeclinePayload, + InvalidDiscountAcceptPayload, + InvalidDiscountDeclinePayload, + EmptyField(&'static str), +} + +impl fmt::Display for SdkRadrootsdPublicTradePublishValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedPayload(message_type) => match message_type { + trade::RadrootsTradeMessageType::OrderRequest => f.write_str( + "trade public publish does not support payload `OrderRequest`; use trade.publish_order_request_via_radrootsd for order requests", + ), + trade::RadrootsTradeMessageType::ListingValidateRequest + | trade::RadrootsTradeMessageType::ListingValidateResult => f.write_str( + "trade public publish does not support listing validation payloads; use trade.validate_listing_event for listing validation", + ), + _ => write!( + f, + "trade public publish does not support payload `{message_type:?}`", + ), + }, + Self::MissingListingSnapshot(message_type) => write!( + f, + "trade public publish requires listing_event for `{message_type:?}`" + ), + Self::ListingSnapshotRelaysEmpty => { + f.write_str("trade public publish listing_event.relays must not be empty") + } + Self::MissingTradeChain(message_type) => write!( + f, + "trade public publish requires root_event_id and prev_event_id for `{message_type:?}`" + ), + Self::InvalidOrderRevisionAcceptPayload => f.write_str( + "trade public publish order revision accept payload must set accepted = true", + ), + Self::InvalidOrderRevisionDeclinePayload => f.write_str( + "trade public publish order revision decline payload must set accepted = false", + ), + Self::InvalidDiscountAcceptPayload => f.write_str( + "trade public publish discount accept payload must be an accept decision", + ), + Self::InvalidDiscountDeclinePayload => f.write_str( + "trade public publish discount decline payload must be a decline decision", + ), + Self::EmptyField(field) => write!(f, "trade public publish field `{field}` must not be empty"), + } + } +} + impl SdkRadrootsdPublicTradePublishRequest { pub fn new( listing_addr: impl Into<String>, @@ -249,6 +371,283 @@ impl SdkRadrootsdPublicTradePublishRequest { } } } + + pub fn order_response( + route: &SdkRadrootsdPublicTradeRoute, + chain: &SdkRadrootsdTradeChain, + payload: trade::RadrootsTradeOrderResponse, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + Self::from_components( + route, + Some(chain), + None, + trade::RadrootsTradeMessagePayload::OrderResponse(payload), + ) + } + + pub fn order_revision( + route: &SdkRadrootsdPublicTradeRoute, + listing_event: RadrootsNostrEventPtr, + chain: &SdkRadrootsdTradeChain, + payload: trade::RadrootsTradeOrderRevision, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + Self::from_components( + route, + Some(chain), + Some(listing_event), + trade::RadrootsTradeMessagePayload::OrderRevision(payload), + ) + } + + pub fn order_revision_accept( + route: &SdkRadrootsdPublicTradeRoute, + chain: &SdkRadrootsdTradeChain, + payload: trade::RadrootsTradeOrderRevisionResponse, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + Self::from_components( + route, + Some(chain), + None, + trade::RadrootsTradeMessagePayload::OrderRevisionAccept(payload), + ) + } + + pub fn order_revision_decline( + route: &SdkRadrootsdPublicTradeRoute, + chain: &SdkRadrootsdTradeChain, + payload: trade::RadrootsTradeOrderRevisionResponse, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + Self::from_components( + route, + Some(chain), + None, + trade::RadrootsTradeMessagePayload::OrderRevisionDecline(payload), + ) + } + + pub fn question( + route: &SdkRadrootsdPublicTradeRoute, + chain: &SdkRadrootsdTradeChain, + payload: trade::RadrootsTradeQuestion, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + Self::from_components( + route, + Some(chain), + None, + trade::RadrootsTradeMessagePayload::Question(payload), + ) + } + + pub fn answer( + route: &SdkRadrootsdPublicTradeRoute, + chain: &SdkRadrootsdTradeChain, + payload: trade::RadrootsTradeAnswer, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + Self::from_components( + route, + Some(chain), + None, + trade::RadrootsTradeMessagePayload::Answer(payload), + ) + } + + pub fn discount_request( + route: &SdkRadrootsdPublicTradeRoute, + listing_event: RadrootsNostrEventPtr, + chain: &SdkRadrootsdTradeChain, + payload: trade::RadrootsTradeDiscountRequest, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + Self::from_components( + route, + Some(chain), + Some(listing_event), + trade::RadrootsTradeMessagePayload::DiscountRequest(payload), + ) + } + + pub fn discount_offer( + route: &SdkRadrootsdPublicTradeRoute, + listing_event: RadrootsNostrEventPtr, + chain: &SdkRadrootsdTradeChain, + payload: trade::RadrootsTradeDiscountOffer, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + Self::from_components( + route, + Some(chain), + Some(listing_event), + trade::RadrootsTradeMessagePayload::DiscountOffer(payload), + ) + } + + pub fn discount_accept( + route: &SdkRadrootsdPublicTradeRoute, + chain: &SdkRadrootsdTradeChain, + payload: trade::RadrootsTradeDiscountDecision, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + Self::from_components( + route, + Some(chain), + None, + trade::RadrootsTradeMessagePayload::DiscountAccept(payload), + ) + } + + pub fn discount_decline( + route: &SdkRadrootsdPublicTradeRoute, + chain: &SdkRadrootsdTradeChain, + payload: trade::RadrootsTradeDiscountDecision, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + Self::from_components( + route, + Some(chain), + None, + trade::RadrootsTradeMessagePayload::DiscountDecline(payload), + ) + } + + pub fn cancel( + route: &SdkRadrootsdPublicTradeRoute, + chain: &SdkRadrootsdTradeChain, + payload: trade::RadrootsTradeListingCancel, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + Self::from_components( + route, + Some(chain), + None, + trade::RadrootsTradeMessagePayload::Cancel(payload), + ) + } + + pub fn fulfillment_update( + route: &SdkRadrootsdPublicTradeRoute, + chain: &SdkRadrootsdTradeChain, + payload: trade::RadrootsTradeFulfillmentUpdate, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + Self::from_components( + route, + Some(chain), + None, + trade::RadrootsTradeMessagePayload::FulfillmentUpdate(payload), + ) + } + + pub fn receipt( + route: &SdkRadrootsdPublicTradeRoute, + chain: &SdkRadrootsdTradeChain, + payload: trade::RadrootsTradeReceipt, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + Self::from_components( + route, + Some(chain), + None, + trade::RadrootsTradeMessagePayload::Receipt(payload), + ) + } + + pub fn validate_for_publish( + &self, + ) -> Result<trade::RadrootsTradeMessageType, SdkRadrootsdPublicTradePublishValidationError> + { + normalize_required_string(self.listing_addr.as_str(), "listing_addr")?; + normalize_required_string(self.order_id.as_str(), "order_id")?; + normalize_required_string(self.counterparty_pubkey.as_str(), "counterparty_pubkey")?; + + let message_type = self.payload.message_type(); + if !message_type.is_public() + || matches!(message_type, trade::RadrootsTradeMessageType::OrderRequest) + { + return Err( + SdkRadrootsdPublicTradePublishValidationError::UnsupportedPayload(message_type), + ); + } + + if message_type.requires_listing_snapshot() { + let Some(listing_event) = self.listing_event.as_ref() else { + return Err( + SdkRadrootsdPublicTradePublishValidationError::MissingListingSnapshot( + message_type, + ), + ); + }; + if listing_event + .relays + .as_ref() + .is_some_and(|relay| relay.trim().is_empty()) + { + return Err( + SdkRadrootsdPublicTradePublishValidationError::ListingSnapshotRelaysEmpty, + ); + } + } + + if message_type.requires_trade_chain() { + let Some(root_event_id) = self.root_event_id.as_deref() else { + return Err( + SdkRadrootsdPublicTradePublishValidationError::MissingTradeChain(message_type), + ); + }; + let Some(prev_event_id) = self.prev_event_id.as_deref() else { + return Err( + SdkRadrootsdPublicTradePublishValidationError::MissingTradeChain(message_type), + ); + }; + normalize_required_string(root_event_id, "root_event_id")?; + normalize_required_string(prev_event_id, "prev_event_id")?; + } + + match &self.payload { + trade::RadrootsTradeMessagePayload::OrderRevisionAccept(response) + if !response.accepted => + { + return Err( + SdkRadrootsdPublicTradePublishValidationError::InvalidOrderRevisionAcceptPayload, + ) + } + trade::RadrootsTradeMessagePayload::OrderRevisionDecline(response) + if response.accepted => + { + return Err( + SdkRadrootsdPublicTradePublishValidationError::InvalidOrderRevisionDeclinePayload, + ) + } + trade::RadrootsTradeMessagePayload::DiscountAccept( + trade::RadrootsTradeDiscountDecision::Decline { .. }, + ) => { + return Err( + SdkRadrootsdPublicTradePublishValidationError::InvalidDiscountAcceptPayload, + ) + } + trade::RadrootsTradeMessagePayload::DiscountDecline( + trade::RadrootsTradeDiscountDecision::Accept { .. }, + ) => { + return Err( + SdkRadrootsdPublicTradePublishValidationError::InvalidDiscountDeclinePayload, + ) + } + _ => {} + } + + Ok(message_type) + } + + fn from_components( + route: &SdkRadrootsdPublicTradeRoute, + chain: Option<&SdkRadrootsdTradeChain>, + listing_event: Option<RadrootsNostrEventPtr>, + payload: trade::RadrootsTradeMessagePayload, + ) -> Result<Self, SdkRadrootsdPublicTradePublishValidationError> { + let request = Self { + listing_addr: route.listing_addr().to_owned(), + order_id: route.order_id().to_owned(), + counterparty_pubkey: route.counterparty_pubkey().to_owned(), + listing_event, + root_event_id: chain.map(|chain| chain.root_event_id().to_owned()), + prev_event_id: chain.map(|chain| chain.prev_event_id().to_owned()), + payload, + }; + request.validate_for_publish()?; + Ok(request) + } } impl fmt::Debug for SdkRadrootsdPublicTradePublishRequest { @@ -265,6 +664,19 @@ impl fmt::Debug for SdkRadrootsdPublicTradePublishRequest { } } +fn normalize_required_string( + value: impl Into<String>, + field: &'static str, +) -> Result<String, SdkRadrootsdPublicTradePublishValidationError> { + let value = value.into().trim().to_owned(); + if value.is_empty() { + return Err(SdkRadrootsdPublicTradePublishValidationError::EmptyField( + field, + )); + } + Ok(value) +} + impl SdkRadrootsdListingPublishRequest { pub fn from_event( event: &RadrootsNostrEvent, diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs @@ -1743,20 +1743,16 @@ impl<'a> TradeClient<'a> { request: &radrootsd::SdkRadrootsdPublicTradePublishRequest, options: &SdkRadrootsdPublicTradePublishOptions, ) -> Result<SdkPublishReceipt, SdkPublishError> { - match request.message_type() { - Some(_) => { - self.client - .publish_public_trade_via_radrootsd( - request, - options.session(), - options.idempotency_key(), - ) - .await - } - None => Err(SdkPublishError::Encode( - "trade.publish_public_message_via_radrootsd requires a bridge.order.* public trade payload; use trade.publish_order_request_via_radrootsd for order requests".to_owned(), - )), - } + request + .validate_for_publish() + .map_err(|err| SdkPublishError::Encode(err.to_string()))?; + self.client + .publish_public_trade_via_radrootsd( + request, + options.session(), + options.idempotency_key(), + ) + .await } } diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -29,8 +29,9 @@ pub mod trade; pub use crate::adapters::radrootsd::{ SdkRadrootsdBridgeDeliveryPolicy, SdkRadrootsdBridgeJobStatus, SdkRadrootsdBridgeRelayPublishResult, SdkRadrootsdPublicTradePublishRequest, + SdkRadrootsdPublicTradePublishValidationError, SdkRadrootsdPublicTradeRoute, SdkRadrootsdSignerAuthority, SdkRadrootsdSignerSessionConnectRequest, - SdkRadrootsdSignerSessionMode, SdkRadrootsdSignerSessionRole, + SdkRadrootsdSignerSessionMode, SdkRadrootsdSignerSessionRole, SdkRadrootsdTradeChain, }; pub use crate::client::{ FarmClient, ListingClient, ProfileClient, RadrootsSdkClient, SdkPublishError, diff --git a/crates/sdk/tests/radrootsd.rs b/crates/sdk/tests/radrootsd.rs @@ -16,17 +16,19 @@ use radroots_sdk::listing::{ RadrootsListingProduct, RadrootsListingStatus, RadrootsTradeListingParseError, }; use radroots_sdk::trade::{ - RadrootsTradeMessagePayload, RadrootsTradeMessageType, RadrootsTradeOrder, - RadrootsTradeOrderItem, RadrootsTradeOrderResponse, + RadrootsTradeDiscountDecision, RadrootsTradeMessagePayload, RadrootsTradeMessageType, + RadrootsTradeOrder, RadrootsTradeOrderItem, RadrootsTradeOrderResponse, + RadrootsTradeOrderRevision, RadrootsTradeOrderRevisionResponse, }; use radroots_sdk::{ - RadrootsNostrEvent, RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, RadrootsdConfig, - SdkConfigError, SdkEnvironment, SdkPublishError, SdkRadrootsdBridgeDeliveryPolicy, - SdkRadrootsdBridgeError, SdkRadrootsdBridgeJobStatus, SdkRadrootsdListingPublishOptions, - SdkRadrootsdOrderRequestPublishOptions, SdkRadrootsdPublicTradePublishOptions, - SdkRadrootsdPublishReceipt, SdkRadrootsdSessionError, SdkRadrootsdSignerSessionHandle, - SdkRadrootsdSignerSessionRole, SdkRadrootsdSignerSessionView, SdkTransportMode, - SdkTransportReceipt, SignerConfig, + RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, + RadrootsdConfig, SdkConfigError, SdkEnvironment, SdkPublishError, + SdkRadrootsdBridgeDeliveryPolicy, SdkRadrootsdBridgeError, SdkRadrootsdBridgeJobStatus, + SdkRadrootsdListingPublishOptions, SdkRadrootsdOrderRequestPublishOptions, + SdkRadrootsdPublicTradePublishOptions, SdkRadrootsdPublicTradePublishValidationError, + SdkRadrootsdPublicTradeRoute, SdkRadrootsdPublishReceipt, SdkRadrootsdSessionError, + SdkRadrootsdSignerSessionHandle, SdkRadrootsdSignerSessionRole, SdkRadrootsdSignerSessionView, + SdkRadrootsdTradeChain, SdkTransportMode, SdkTransportReceipt, SignerConfig, }; use serde_json::{Value, json}; use std::collections::VecDeque; @@ -390,16 +392,36 @@ fn sample_trade_order() -> RadrootsTradeOrder { } fn sample_public_trade_request() -> SdkRadrootsdPublicTradePublishRequest { - SdkRadrootsdPublicTradePublishRequest::new( + SdkRadrootsdPublicTradePublishRequest::order_response( + &sample_public_trade_route(), + &sample_trade_chain(), + RadrootsTradeOrderResponse { + accepted: true, + reason: None, + }, + ) + .expect("sample order response request should be valid") +} + +fn sample_public_trade_route() -> SdkRadrootsdPublicTradeRoute { + SdkRadrootsdPublicTradeRoute::new( format!("{KIND_LISTING}:seller:AAAAAAAAAAAAAAAAAAAAAg"), "order-1", "buyer", - RadrootsTradeMessagePayload::OrderResponse(RadrootsTradeOrderResponse { - accepted: true, - reason: None, - }), ) - .with_trade_chain("root-event-1", "prev-event-1") + .expect("sample public trade route should be valid") +} + +fn sample_trade_chain() -> SdkRadrootsdTradeChain { + SdkRadrootsdTradeChain::new("root-event-1", "prev-event-1") + .expect("sample trade chain should be valid") +} + +fn listing_event_ptr_with_relays(relays: Option<&str>) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: "listing-event-1".to_owned(), + relays: relays.map(str::to_owned), + } } fn sdk_event( @@ -1473,6 +1495,104 @@ async fn radrootsd_trade_public_message_publish_rejects_order_request_payload() Ok(()) } +#[test] +fn public_trade_request_validation_requires_listing_snapshot_for_order_revision() { + let error = SdkRadrootsdPublicTradePublishRequest::new( + format!("{KIND_LISTING}:seller:AAAAAAAAAAAAAAAAAAAAAg"), + "order-1", + "buyer", + RadrootsTradeMessagePayload::OrderRevision(RadrootsTradeOrderRevision { + revision_id: "revision-1".to_owned(), + changes: Vec::new(), + }), + ) + .validate_for_publish() + .expect_err("order revision without listing snapshot should be rejected"); + + assert_eq!( + error, + SdkRadrootsdPublicTradePublishValidationError::MissingListingSnapshot( + RadrootsTradeMessageType::OrderRevision, + ) + ); +} + +#[test] +fn public_trade_request_validation_requires_trade_chain_for_order_response() { + let error = SdkRadrootsdPublicTradePublishRequest::new( + format!("{KIND_LISTING}:seller:AAAAAAAAAAAAAAAAAAAAAg"), + "order-1", + "buyer", + RadrootsTradeMessagePayload::OrderResponse(RadrootsTradeOrderResponse { + accepted: true, + reason: None, + }), + ) + .validate_for_publish() + .expect_err("order response without trade chain should be rejected"); + + assert_eq!( + error, + SdkRadrootsdPublicTradePublishValidationError::MissingTradeChain( + RadrootsTradeMessageType::OrderResponse, + ) + ); +} + +#[test] +fn public_trade_request_validation_rejects_blank_listing_snapshot_relays() { + let error = SdkRadrootsdPublicTradePublishRequest::order_revision( + &sample_public_trade_route(), + listing_event_ptr_with_relays(Some(" ")), + &sample_trade_chain(), + RadrootsTradeOrderRevision { + revision_id: "revision-1".to_owned(), + changes: Vec::new(), + }, + ) + .expect_err("blank listing_event relays should be rejected"); + + assert_eq!( + error, + SdkRadrootsdPublicTradePublishValidationError::ListingSnapshotRelaysEmpty + ); +} + +#[test] +fn public_trade_request_validation_rejects_invalid_order_revision_accept_payload() { + let error = SdkRadrootsdPublicTradePublishRequest::order_revision_accept( + &sample_public_trade_route(), + &sample_trade_chain(), + RadrootsTradeOrderRevisionResponse { + accepted: false, + reason: Some("not accepted".to_owned()), + }, + ) + .expect_err("order revision accept must require accepted = true"); + + assert_eq!( + error, + SdkRadrootsdPublicTradePublishValidationError::InvalidOrderRevisionAcceptPayload + ); +} + +#[test] +fn public_trade_request_validation_rejects_invalid_discount_accept_payload() { + let error = SdkRadrootsdPublicTradePublishRequest::discount_accept( + &sample_public_trade_route(), + &sample_trade_chain(), + RadrootsTradeDiscountDecision::Decline { + reason: Some("declined".to_owned()), + }, + ) + .expect_err("discount accept must use an accept decision"); + + assert_eq!( + error, + SdkRadrootsdPublicTradePublishValidationError::InvalidDiscountAcceptPayload + ); +} + #[tokio::test] async fn radrootsd_sdk_workflow_chains_session_listing_trade_and_bridge_job() -> TestResult<()> { let (server, mut request_rx) = JsonRpcSequenceServer::spawn(