commit 112fb63daacc70248ff4e4fb7ce97f94eed00482
parent 204824fc93cde614ff3622297ad391930c7207e1
Author: triesap <tyson@radroots.org>
Date: Wed, 3 Jun 2026 00:31:37 -0700
sdk: add active trade lifecycle publish helpers
- add SDK draft builders for fulfillment, cancellation, and buyer receipt events
- expose parse helpers for active trade lifecycle envelopes
- wire relay-direct publish methods for lifecycle payloads and drafts
- cover lifecycle publishing with relay-direct SDK tests
Diffstat:
3 files changed, 507 insertions(+), 3 deletions(-)
diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs
@@ -2319,6 +2319,36 @@ impl<'a> TradeClient<'a> {
}
#[cfg(feature = "serde_json")]
+ pub fn build_fulfillment_update_draft(
+ &self,
+ root_event_id: &str,
+ prev_event_id: &str,
+ payload: &trade::RadrootsTradeFulfillmentUpdated,
+ ) -> Result<trade::RadrootsTradeFulfillmentUpdateDraft, trade::EventEncodeError> {
+ trade::build_fulfillment_update_draft(root_event_id, prev_event_id, payload)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_order_cancellation_draft(
+ &self,
+ root_event_id: &str,
+ prev_event_id: &str,
+ payload: &trade::RadrootsTradeOrderCancelled,
+ ) -> Result<trade::RadrootsTradeOrderCancellationDraft, trade::EventEncodeError> {
+ trade::build_order_cancellation_draft(root_event_id, prev_event_id, payload)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_buyer_receipt_draft(
+ &self,
+ root_event_id: &str,
+ prev_event_id: &str,
+ payload: &trade::RadrootsTradeBuyerReceipt,
+ ) -> Result<trade::RadrootsTradeBuyerReceiptDraft, trade::EventEncodeError> {
+ trade::build_buyer_receipt_draft(root_event_id, prev_event_id, payload)
+ }
+
+ #[cfg(feature = "serde_json")]
pub fn parse_order_request(
&self,
event: &RadrootsNostrEvent,
@@ -2340,6 +2370,39 @@ impl<'a> TradeClient<'a> {
trade::parse_order_decision(event)
}
+ #[cfg(feature = "serde_json")]
+ pub fn parse_fulfillment_update(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<
+ trade::RadrootsActiveTradeEnvelope<trade::RadrootsTradeFulfillmentUpdated>,
+ trade::RadrootsActiveTradeEnvelopeParseError,
+ > {
+ trade::parse_fulfillment_update(event)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_order_cancellation(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<
+ trade::RadrootsActiveTradeEnvelope<trade::RadrootsTradeOrderCancelled>,
+ trade::RadrootsActiveTradeEnvelopeParseError,
+ > {
+ trade::parse_order_cancellation(event)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_buyer_receipt(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<
+ trade::RadrootsActiveTradeEnvelope<trade::RadrootsTradeBuyerReceipt>,
+ trade::RadrootsActiveTradeEnvelopeParseError,
+ > {
+ trade::parse_buyer_receipt(event)
+ }
+
#[cfg(all(
feature = "identity-models",
feature = "relay-client",
@@ -2390,6 +2453,75 @@ impl<'a> TradeClient<'a> {
feature = "relay-client",
feature = "signing"
))]
+ pub async fn publish_fulfillment_update_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ root_event_id: &str,
+ prev_event_id: &str,
+ payload: &trade::RadrootsTradeFulfillmentUpdated,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let draft = trade::build_fulfillment_update_draft(root_event_id, prev_event_id, payload)
+ .map_err(|err| SdkPublishError::Encode(err.to_string()))?;
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "trade.publish_fulfillment_update_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_order_cancellation_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ root_event_id: &str,
+ prev_event_id: &str,
+ payload: &trade::RadrootsTradeOrderCancelled,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let draft = trade::build_order_cancellation_draft(root_event_id, prev_event_id, 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_cancellation_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_buyer_receipt_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ root_event_id: &str,
+ prev_event_id: &str,
+ payload: &trade::RadrootsTradeBuyerReceipt,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let draft = trade::build_buyer_receipt_draft(root_event_id, prev_event_id, payload)
+ .map_err(|err| SdkPublishError::Encode(err.to_string()))?;
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "trade.publish_buyer_receipt_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,
@@ -2423,6 +2555,63 @@ impl<'a> TradeClient<'a> {
.await
}
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_fulfillment_update_draft_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ draft: trade::RadrootsTradeFulfillmentUpdateDraft,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "trade.publish_fulfillment_update_draft_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_order_cancellation_draft_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ draft: trade::RadrootsTradeOrderCancellationDraft,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "trade.publish_order_cancellation_draft_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_buyer_receipt_draft_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ draft: trade::RadrootsTradeBuyerReceiptDraft,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "trade.publish_buyer_receipt_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
@@ -20,6 +20,21 @@ pub struct RadrootsTradeOrderDecisionDraft {
parts: WireEventParts,
}
+#[derive(Debug, Clone)]
+pub struct RadrootsTradeFulfillmentUpdateDraft {
+ parts: WireEventParts,
+}
+
+#[derive(Debug, Clone)]
+pub struct RadrootsTradeOrderCancellationDraft {
+ parts: WireEventParts,
+}
+
+#[derive(Debug, Clone)]
+pub struct RadrootsTradeBuyerReceiptDraft {
+ parts: WireEventParts,
+}
+
impl RadrootsTradeOrderRequestDraft {
pub fn as_wire_parts(&self) -> &WireEventParts {
&self.parts
@@ -40,6 +55,36 @@ impl RadrootsTradeOrderDecisionDraft {
}
}
+impl RadrootsTradeFulfillmentUpdateDraft {
+ pub fn as_wire_parts(&self) -> &WireEventParts {
+ &self.parts
+ }
+
+ pub fn into_wire_parts(self) -> WireEventParts {
+ self.parts
+ }
+}
+
+impl RadrootsTradeOrderCancellationDraft {
+ pub fn as_wire_parts(&self) -> &WireEventParts {
+ &self.parts
+ }
+
+ pub fn into_wire_parts(self) -> WireEventParts {
+ self.parts
+ }
+}
+
+impl RadrootsTradeBuyerReceiptDraft {
+ 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>,
@@ -92,6 +137,51 @@ pub fn build_order_decision_draft(
}
#[cfg(feature = "serde_json")]
+pub fn build_fulfillment_update_draft(
+ root_event_id: &str,
+ prev_event_id: &str,
+ payload: &RadrootsTradeFulfillmentUpdated,
+) -> Result<RadrootsTradeFulfillmentUpdateDraft, EventEncodeError> {
+ Ok(RadrootsTradeFulfillmentUpdateDraft {
+ parts: radroots_events_codec::trade::active_trade_fulfillment_update_event_build(
+ root_event_id,
+ prev_event_id,
+ payload,
+ )?,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+pub fn build_order_cancellation_draft(
+ root_event_id: &str,
+ prev_event_id: &str,
+ payload: &RadrootsTradeOrderCancelled,
+) -> Result<RadrootsTradeOrderCancellationDraft, EventEncodeError> {
+ Ok(RadrootsTradeOrderCancellationDraft {
+ parts: radroots_events_codec::trade::active_trade_order_cancel_event_build(
+ root_event_id,
+ prev_event_id,
+ payload,
+ )?,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+pub fn build_buyer_receipt_draft(
+ root_event_id: &str,
+ prev_event_id: &str,
+ payload: &RadrootsTradeBuyerReceipt,
+) -> Result<RadrootsTradeBuyerReceiptDraft, EventEncodeError> {
+ Ok(RadrootsTradeBuyerReceiptDraft {
+ parts: radroots_events_codec::trade::active_trade_buyer_receipt_event_build(
+ root_event_id,
+ prev_event_id,
+ payload,
+ )?,
+ })
+}
+
+#[cfg(feature = "serde_json")]
pub fn parse_envelope(
event: &RadrootsNostrEvent,
) -> Result<SdkTradeEnvelope, RadrootsTradeEnvelopeParseError> {
@@ -119,6 +209,36 @@ pub fn parse_order_decision(
}
#[cfg(feature = "serde_json")]
+pub fn parse_fulfillment_update(
+ event: &RadrootsNostrEvent,
+) -> Result<
+ RadrootsActiveTradeEnvelope<RadrootsTradeFulfillmentUpdated>,
+ RadrootsActiveTradeEnvelopeParseError,
+> {
+ radroots_events_codec::trade::active_trade_fulfillment_update_from_event(event)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn parse_order_cancellation(
+ event: &RadrootsNostrEvent,
+) -> Result<
+ RadrootsActiveTradeEnvelope<RadrootsTradeOrderCancelled>,
+ RadrootsActiveTradeEnvelopeParseError,
+> {
+ radroots_events_codec::trade::active_trade_order_cancel_from_event(event)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn parse_buyer_receipt(
+ event: &RadrootsNostrEvent,
+) -> Result<
+ RadrootsActiveTradeEnvelope<RadrootsTradeBuyerReceipt>,
+ RadrootsActiveTradeEnvelopeParseError,
+> {
+ radroots_events_codec::trade::active_trade_buyer_receipt_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
@@ -19,9 +19,11 @@ use radroots_sdk::listing::{
};
use radroots_sdk::profile::{RadrootsProfile, RadrootsProfileType};
use radroots_sdk::trade::{
- RadrootsTradeInventoryCommitment, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent,
- RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem,
- RadrootsTradeOrderRequested, RadrootsTradePricingBasis,
+ RadrootsActiveTradeFulfillmentState, RadrootsTradeBuyerReceipt,
+ RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, RadrootsTradeOrderCancelled,
+ RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomicItem,
+ RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested,
+ RadrootsTradePricingBasis,
};
use radroots_sdk::{
RadrootsNostrEventPtr, RadrootsSdkClient, RadrootsSdkConfig, RelayConfig, SdkEnvironment,
@@ -271,6 +273,44 @@ fn sample_order_decision(
}
}
+fn sample_fulfillment_update(
+ buyer_pubkey: String,
+ seller_pubkey: String,
+) -> RadrootsTradeFulfillmentUpdated {
+ RadrootsTradeFulfillmentUpdated {
+ order_id: "order-1".into(),
+ listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"),
+ buyer_pubkey,
+ seller_pubkey,
+ status: RadrootsActiveTradeFulfillmentState::ReadyForPickup,
+ }
+}
+
+fn sample_order_cancellation(
+ buyer_pubkey: String,
+ seller_pubkey: String,
+) -> RadrootsTradeOrderCancelled {
+ RadrootsTradeOrderCancelled {
+ order_id: "order-1".into(),
+ listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"),
+ buyer_pubkey,
+ seller_pubkey,
+ reason: "schedule changed".into(),
+ }
+}
+
+fn sample_buyer_receipt(buyer_pubkey: String, seller_pubkey: String) -> RadrootsTradeBuyerReceipt {
+ RadrootsTradeBuyerReceipt {
+ order_id: "order-1".into(),
+ listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"),
+ buyer_pubkey,
+ seller_pubkey,
+ received: true,
+ issue: None,
+ received_at: 1_785_000_000,
+ }
+}
+
#[tokio::test]
async fn relay_direct_farm_publish_accepts_sdk_built_draft() -> TestResult<()> {
let relay = AckRelay::spawn().await?;
@@ -500,6 +540,161 @@ async fn relay_direct_order_decision_publish_accepts_sdk_built_draft() -> TestRe
}
#[tokio::test]
+async fn relay_direct_trade_lifecycle_publish_accepts_sdk_built_payloads() -> TestResult<()> {
+ let relay = AckRelay::spawn().await?;
+ let buyer_identity = RadrootsIdentity::generate();
+ let seller_identity = RadrootsIdentity::generate();
+ let buyer_pubkey = buyer_identity.public_key_hex();
+ let seller_pubkey = seller_identity.public_key_hex();
+ let root_event_id = "order-request-event-1";
+ let decision_event_id = "order-decision-event-1";
+ let fulfillment_event_id = "fulfillment-event-1";
+ let fulfillment = sample_fulfillment_update(buyer_pubkey.clone(), seller_pubkey.clone());
+ let cancellation = sample_order_cancellation(buyer_pubkey.clone(), seller_pubkey.clone());
+ let receipt = sample_buyer_receipt(buyer_pubkey.clone(), seller_pubkey.clone());
+ 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 fulfillment_receipt = client
+ .trade()
+ .publish_fulfillment_update_with_identity(
+ &seller_identity,
+ root_event_id,
+ decision_event_id,
+ &fulfillment,
+ )
+ .await?;
+ let cancellation_receipt = client
+ .trade()
+ .publish_order_cancellation_with_identity(
+ &buyer_identity,
+ root_event_id,
+ root_event_id,
+ &cancellation,
+ )
+ .await?;
+ let buyer_receipt = client
+ .trade()
+ .publish_buyer_receipt_with_identity(
+ &buyer_identity,
+ root_event_id,
+ fulfillment_event_id,
+ &receipt,
+ )
+ .await?;
+
+ assert_eq!(fulfillment_receipt.event_kind, Some(3433));
+ assert_eq!(cancellation_receipt.event_kind, Some(3432));
+ assert_eq!(buyer_receipt.event_kind, Some(3434));
+
+ match fulfillment_receipt.transport_receipt {
+ SdkTransportReceipt::RelayDirect(relay_receipt) => {
+ assert_eq!(relay_receipt.event.kind, 3433);
+ assert_eq!(relay_receipt.event.author, seller_pubkey);
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["p".to_owned(), buyer_pubkey.clone()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_root".to_owned(), root_event_id.to_owned()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_prev".to_owned(), decision_event_id.to_owned()])
+ );
+ let envelope = client
+ .trade()
+ .parse_fulfillment_update(&relay_receipt.event)
+ .expect("active fulfillment update");
+ assert_eq!(envelope.order_id, fulfillment.order_id);
+ assert_eq!(envelope.listing_addr, fulfillment.listing_addr);
+ assert_eq!(envelope.payload.status, fulfillment.status);
+ }
+ SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"),
+ }
+
+ match cancellation_receipt.transport_receipt {
+ SdkTransportReceipt::RelayDirect(relay_receipt) => {
+ assert_eq!(relay_receipt.event.kind, 3432);
+ assert_eq!(relay_receipt.event.author, buyer_pubkey);
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["p".to_owned(), seller_pubkey.clone()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_root".to_owned(), root_event_id.to_owned()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_prev".to_owned(), root_event_id.to_owned()])
+ );
+ let envelope = client
+ .trade()
+ .parse_order_cancellation(&relay_receipt.event)
+ .expect("active order cancellation");
+ assert_eq!(envelope.order_id, cancellation.order_id);
+ assert_eq!(envelope.listing_addr, cancellation.listing_addr);
+ assert_eq!(envelope.payload.reason, cancellation.reason);
+ }
+ SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"),
+ }
+
+ match buyer_receipt.transport_receipt {
+ SdkTransportReceipt::RelayDirect(relay_receipt) => {
+ assert_eq!(relay_receipt.event.kind, 3434);
+ assert_eq!(relay_receipt.event.author, buyer_pubkey);
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["p".to_owned(), seller_pubkey])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_root".to_owned(), root_event_id.to_owned()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_prev".to_owned(), fulfillment_event_id.to_owned()])
+ );
+ let envelope = client
+ .trade()
+ .parse_buyer_receipt(&relay_receipt.event)
+ .expect("active buyer receipt");
+ assert_eq!(envelope.order_id, receipt.order_id);
+ assert_eq!(envelope.listing_addr, receipt.listing_addr);
+ assert_eq!(envelope.payload.received, receipt.received);
+ }
+ SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"),
+ }
+
+ Ok(())
+}
+
+#[tokio::test]
async fn relay_direct_order_decision_publish_builds_and_publishes_payload() -> TestResult<()> {
let relay = AckRelay::spawn().await?;
let buyer_identity = RadrootsIdentity::generate();