lib

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

commit 9b40d20d7193adb5a435aab16ea9a3ec81fd869c
parent 70773bfcdbcb6d10f55a2be9e2a0b1619f093220
Author: triesap <tyson@radroots.org>
Date:   Fri,  8 May 2026 16:29:56 +0000

sdk: add relay order request publish

Diffstat:
Mcrates/runtime_distribution/src/lib.rs | 8++++----
Mcrates/sdk/src/client.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/sdk/src/trade.rs | 42++++++++++++++++++++++++++++++++++++++++--
Mcrates/sdk/tests/relay_direct.rs | 305++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/xtask/src/contract.rs | 2+-
5 files changed, 454 insertions(+), 11 deletions(-)

diff --git a/crates/runtime_distribution/src/lib.rs b/crates/runtime_distribution/src/lib.rs @@ -10,8 +10,8 @@ pub use model::{ RadrootsRuntimeDistributionContract, RuntimeDistributionEntry, TargetSet, TargetSpec, }; pub use resolve::{ - RadrootsRuntimeDistributionResolver, ResolvedRuntimeArtifact, RuntimeArtifactRequest, - RUNTIME_DISTRIBUTION_SCHEMA, + RUNTIME_DISTRIBUTION_SCHEMA, RadrootsRuntimeDistributionResolver, ResolvedRuntimeArtifact, + RuntimeArtifactRequest, }; #[cfg(test)] @@ -19,8 +19,8 @@ mod tests { use toml::Value; use super::{ - RadrootsRuntimeDistributionError, RadrootsRuntimeDistributionResolver, - RuntimeArtifactRequest, RUNTIME_DISTRIBUTION_SCHEMA, + RUNTIME_DISTRIBUTION_SCHEMA, RadrootsRuntimeDistributionError, + RadrootsRuntimeDistributionResolver, RuntimeArtifactRequest, }; const CONTRACT: &str = r#" diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs @@ -129,6 +129,12 @@ pub enum SdkPublishError { operation: &'static str, }, Relay(String), + RelaySetup { + transport: SdkTransportMode, + operation: &'static str, + target_relays: Vec<String>, + error: String, + }, RelayNotAcknowledged { transport: SdkTransportMode, failed_relays: Vec<SdkRelayFailure>, @@ -166,6 +172,25 @@ impl core::fmt::Display for SdkPublishError { "{operation} requires signer mode `{required}` for {transport:?} transport, got `{signer}`" ), Self::Relay(message) => write!(f, "{message}"), + Self::RelaySetup { + transport, + operation, + target_relays, + error, + } => { + if target_relays.is_empty() { + write!( + f, + "{operation} failed to prepare {transport:?} relay publish: {error}" + ) + } else { + let relays = target_relays.join(", "); + write!( + f, + "{operation} failed to prepare {transport:?} relay publish for {relays}: {error}" + ) + } + } Self::RelayNotAcknowledged { transport, failed_relays, @@ -1065,13 +1090,31 @@ impl RadrootsSdkClient { Duration::from_millis(self.config.network.timeout_ms), ) .await - .map_err(|err| SdkPublishError::Relay(err.to_string()))?; + .map_err(|err| SdkPublishError::RelaySetup { + transport: SdkTransportMode::RelayDirect, + operation, + target_relays: relay_urls.clone(), + error: err.to_string(), + })?; let connected_relays = relay::connected_relay_urls(&client).await; + if connected_relays.is_empty() { + return Err(SdkPublishError::RelaySetup { + transport: SdkTransportMode::RelayDirect, + operation, + target_relays: relay_urls, + error: "no relay connection was established".to_owned(), + }); + } let signed_event = signing::sign_parts_with_identity(identity, parts) .map_err(|err| SdkPublishError::Relay(err.to_string()))?; let output = relay::publish_signed_event(&client, &signed_event) .await - .map_err(|err| SdkPublishError::Relay(err.to_string()))?; + .map_err(|err| SdkPublishError::RelaySetup { + transport: SdkTransportMode::RelayDirect, + operation, + target_relays: relay_urls.clone(), + error: err.to_string(), + })?; sdk_publish_receipt_from_relay_output(signed_event, relay_urls, connected_relays, output) } @@ -2256,6 +2299,67 @@ impl<'a> TradeClient<'a> { trade::validate_listing_event(event) } + #[cfg(feature = "serde_json")] + pub fn build_order_request_draft( + &self, + listing_event: &RadrootsNostrEventPtr, + payload: &trade::RadrootsTradeOrderRequested, + ) -> Result<trade::RadrootsTradeOrderRequestDraft, trade::EventEncodeError> { + trade::build_order_request_draft(listing_event, payload) + } + + #[cfg(feature = "serde_json")] + pub fn parse_order_request( + &self, + event: &RadrootsNostrEvent, + ) -> Result< + trade::RadrootsActiveTradeEnvelope<trade::RadrootsTradeOrderRequested>, + trade::RadrootsActiveTradeEnvelopeParseError, + > { + trade::parse_order_request(event) + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_order_request_with_identity( + &self, + identity: &RadrootsIdentity, + listing_event: &RadrootsNostrEventPtr, + payload: &trade::RadrootsTradeOrderRequested, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let draft = trade::build_order_request_draft(listing_event, payload) + .map_err(|err| SdkPublishError::Encode(err.to_string()))?; + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "trade.publish_order_request_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_order_request_draft_with_identity( + &self, + identity: &RadrootsIdentity, + draft: trade::RadrootsTradeOrderRequestDraft, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "trade.publish_order_request_draft_with_identity", + ) + .await + } + #[cfg(feature = "radrootsd-client")] pub async fn publish_order_request_via_radrootsd( &self, diff --git a/crates/sdk/src/trade.rs b/crates/sdk/src/trade.rs @@ -2,14 +2,29 @@ pub use radroots_events::trade::*; pub use radroots_events_codec::error::EventEncodeError; #[cfg(feature = "serde_json")] pub use radroots_events_codec::trade::{ - RadrootsTradeEnvelopeParseError, RadrootsTradeEventContext, RadrootsTradeListingAddress, - RadrootsTradeListingAddressError, + RadrootsActiveTradeEnvelopeParseError, RadrootsTradeEnvelopeParseError, + RadrootsTradeEventContext, RadrootsTradeListingAddress, RadrootsTradeListingAddressError, }; pub use radroots_trade::listing::validation::RadrootsTradeListing as TradeListingValidateResult; use crate::RadrootsTradeEnvelope as SdkTradeEnvelope; use crate::{RadrootsNostrEvent, RadrootsNostrEventPtr, WireEventParts}; +#[derive(Debug, Clone)] +pub struct RadrootsTradeOrderRequestDraft { + parts: WireEventParts, +} + +impl RadrootsTradeOrderRequestDraft { + pub fn as_wire_parts(&self) -> &WireEventParts { + &self.parts + } + + pub fn into_wire_parts(self) -> WireEventParts { + self.parts + } +} + #[cfg(feature = "serde_json")] pub fn build_envelope_draft( recipient_pubkey: impl Into<String>, @@ -34,6 +49,19 @@ pub fn build_envelope_draft( } #[cfg(feature = "serde_json")] +pub fn build_order_request_draft( + listing_event: &RadrootsNostrEventPtr, + payload: &RadrootsTradeOrderRequested, +) -> Result<RadrootsTradeOrderRequestDraft, EventEncodeError> { + Ok(RadrootsTradeOrderRequestDraft { + parts: radroots_events_codec::trade::active_trade_order_request_event_build( + listing_event, + payload, + )?, + }) +} + +#[cfg(feature = "serde_json")] pub fn parse_envelope( event: &RadrootsNostrEvent, ) -> Result<SdkTradeEnvelope, RadrootsTradeEnvelopeParseError> { @@ -41,6 +69,16 @@ pub fn parse_envelope( } #[cfg(feature = "serde_json")] +pub fn parse_order_request( + event: &RadrootsNostrEvent, +) -> Result< + RadrootsActiveTradeEnvelope<RadrootsTradeOrderRequested>, + RadrootsActiveTradeEnvelopeParseError, +> { + radroots_events_codec::trade::active_trade_order_request_from_event(event) +} + +#[cfg(feature = "serde_json")] pub fn parse_listing_address( listing_addr: &str, ) -> Result<RadrootsTradeListingAddress, RadrootsTradeListingAddressError> { diff --git a/crates/sdk/tests/relay_direct.rs b/crates/sdk/tests/relay_direct.rs @@ -18,9 +18,13 @@ use radroots_sdk::listing::{ RadrootsListingStatus, }; use radroots_sdk::profile::{RadrootsProfile, RadrootsProfileType}; +use radroots_sdk::trade::{ + RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, + RadrootsTradeOrderRequested, RadrootsTradePricingBasis, +}; use radroots_sdk::{ - RadrootsSdkClient, RadrootsSdkConfig, RelayConfig, SdkEnvironment, SdkPublishError, - SdkTransportMode, SdkTransportReceipt, SignerConfig, + RadrootsNostrEventPtr, RadrootsSdkClient, RadrootsSdkConfig, RelayConfig, SdkEnvironment, + SdkPublishError, SdkTransportMode, SdkTransportReceipt, SignerConfig, }; use tokio::net::TcpListener; use tokio::sync::oneshot; @@ -196,6 +200,58 @@ fn sample_farm() -> RadrootsFarm { } } +fn decimal(raw: &str) -> RadrootsCoreDecimal { + raw.parse().expect("decimal") +} + +fn usd(raw: &str) -> RadrootsCoreMoney { + RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD) +} + +fn listing_event_ptr() -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: "listing-event-1".into(), + relays: Some("wss://listing.relay.example".into()), + } +} + +fn sample_order_request( + buyer_pubkey: String, + seller_pubkey: String, +) -> RadrootsTradeOrderRequested { + RadrootsTradeOrderRequested { + order_id: "order-1".into(), + listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"), + buyer_pubkey, + seller_pubkey, + items: vec![RadrootsTradeOrderItem { + bin_id: "bin-1".into(), + bin_count: 2, + }], + economics: RadrootsTradeOrderEconomics { + quote_id: "quote-1".into(), + quote_version: 1, + pricing_basis: RadrootsTradePricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsTradeOrderEconomicItem { + bin_id: "bin-1".into(), + bin_count: 2, + quantity_amount: decimal("1"), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: decimal("5"), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: usd("10"), + }], + discounts: Vec::new(), + adjustments: Vec::new(), + subtotal: usd("10"), + discount_total: usd("0"), + adjustment_total: usd("0"), + total: usd("10"), + }, + } +} + #[tokio::test] async fn relay_direct_farm_publish_accepts_sdk_built_draft() -> TestResult<()> { let relay = AckRelay::spawn().await?; @@ -247,6 +303,251 @@ async fn relay_direct_farm_publish_accepts_sdk_built_draft() -> TestResult<()> { } #[tokio::test] +async fn relay_direct_order_request_publish_accepts_sdk_built_draft() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let listing_event = listing_event_ptr(); + let payload = sample_order_request( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + let draft = client + .trade() + .build_order_request_draft(&listing_event, &payload)?; + assert_eq!(draft.as_wire_parts().kind, 3422); + + let receipt = client + .trade() + .publish_order_request_draft_with_identity(&buyer_identity, draft) + .await?; + + assert_eq!(receipt.transport, SdkTransportMode::RelayDirect); + assert_eq!(receipt.event_kind, Some(3422)); + assert!(receipt.event_id.is_some()); + match receipt.transport_receipt { + SdkTransportReceipt::RelayDirect(relay_receipt) => { + assert_eq!( + receipt.event_id.as_deref(), + Some(relay_receipt.event_id.as_str()) + ); + assert_eq!(receipt.event_kind, Some(relay_receipt.event_kind)); + assert_eq!(relay_receipt.event.kind, 3422); + assert_eq!(relay_receipt.event_id, relay_receipt.event.id); + assert_eq!(relay_receipt.signature, relay_receipt.event.sig); + assert_eq!(relay_receipt.created_at, relay_receipt.event.created_at); + assert_eq!(relay_receipt.event.author, buyer_identity.public_key_hex()); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["p".to_owned(), seller_identity.public_key_hex()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["a".to_owned(), payload.listing_addr.clone()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["d".to_owned(), payload.order_id.clone()]) + ); + assert!(relay_receipt.event.tags.contains(&vec![ + "listing_event".to_owned(), + listing_event.id.clone(), + listing_event.relays.clone().expect("listing relay") + ])); + assert_eq!(relay_receipt.target_relays, vec![relay.url().to_owned()]); + assert_eq!(relay_receipt.connected_relays, vec![relay.url().to_owned()]); + assert_eq!( + relay_receipt.acknowledged_relays, + vec![relay.url().to_owned()] + ); + assert!(relay_receipt.failed_relays.is_empty()); + let envelope = client + .trade() + .parse_order_request(&relay_receipt.event) + .expect("active order request"); + assert_eq!(envelope.order_id, payload.order_id); + assert_eq!(envelope.listing_addr, payload.listing_addr); + assert_eq!(envelope.payload.economics.quote_id, "quote-1"); + assert_eq!(envelope.payload.economics.total, usd("10")); + } + SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"), + } + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_request_publish_builds_and_publishes_payload() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let payload = sample_order_request( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let receipt = client + .trade() + .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload) + .await?; + + assert_eq!(receipt.transport, SdkTransportMode::RelayDirect); + assert_eq!(receipt.event_kind, Some(3422)); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_request_publish_rejects_radrootsd_transport_mode() -> TestResult<()> { + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let payload = sample_order_request( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + let mut config = RadrootsSdkConfig::production(); + config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::LocalIdentity; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .trade() + .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload) + .await + .expect_err("unsupported transport"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedTransport { + transport: SdkTransportMode::Radrootsd, + operation: "trade.publish_order_request_with_identity", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_request_publish_rejects_draft_only_signer_mode() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let payload = sample_order_request( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::DraftOnly; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .trade() + .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload) + .await + .expect_err("unsupported signer mode"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedSignerMode { + transport: SdkTransportMode::RelayDirect, + signer: SignerConfig::DraftOnly, + required: SignerConfig::LocalIdentity, + operation: "trade.publish_order_request_with_identity", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_request_publish_rejects_invalid_economics() -> TestResult<()> { + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let mut payload = sample_order_request( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + payload.economics.items[0].bin_count = 1; + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.relay = RelayConfig { + urls: vec!["ws://127.0.0.1:9".to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .trade() + .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload) + .await + .expect_err("invalid economics"); + + assert!(matches!(error, SdkPublishError::Encode(_))); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_request_publish_reports_setup_error_detail() -> TestResult<()> { + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let payload = sample_order_request( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.network.timeout_ms = 10; + config.relay = RelayConfig { + urls: vec!["ws://127.0.0.1:9".to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .trade() + .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload) + .await + .expect_err("relay setup error"); + + assert!(matches!( + error, + SdkPublishError::RelaySetup { + transport: SdkTransportMode::RelayDirect, + operation: "trade.publish_order_request_with_identity", + target_relays, + error: _, + } if target_relays == vec!["ws://127.0.0.1:9".to_owned()] + )); + + Ok(()) +} + +#[tokio::test] async fn relay_direct_farm_publish_rejects_radrootsd_transport_mode() -> TestResult<()> { let identity = RadrootsIdentity::generate(); let mut config = RadrootsSdkConfig::production(); diff --git a/crates/xtask/src/contract.rs b/crates/xtask/src/contract.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] -use crate::coverage::{read_coverage_policy, CoveragePolicyFile, CoverageThresholds}; +use crate::coverage::{CoveragePolicyFile, CoverageThresholds, read_coverage_policy}; use serde::Deserialize; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet};