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:
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};