commit a9d58022a41f36f2ec33fb53ecf9ed2e57360b80
parent 4eb56c578968161e1b9ce2f4ae65fae3a4f3fd70
Author: triesap <tyson@radroots.org>
Date: Mon, 15 Jun 2026 14:04:20 -0700
sdk: add listing publish runtime
- add ListingsClient prepare_publish and enqueue_publish over canonical trade drafts
- ingest signed listing events locally before signed outbox enqueue
- cover side-effect-free prepare, signed queueing, signer errors, and partial local mutation recovery
- validation: cargo check -p radroots_sdk --features runtime; cargo test -p radroots_sdk --features runtime
Diffstat:
5 files changed, 522 insertions(+), 3 deletions(-)
diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs
@@ -25,6 +25,8 @@ pub enum RadrootsSdkError {
TimestampOutOfRange { value: u64 },
Authority { message: String },
EventStore { message: String },
+ ListingDraft { message: String },
+ ListingMutation { message: String },
Outbox { message: String },
RelayTransport { message: String },
Projection { message: String },
@@ -62,6 +64,10 @@ impl fmt::Display for RadrootsSdkError {
}
Self::Authority { message } => write!(f, "sdk authority error: {message}"),
Self::EventStore { message } => write!(f, "sdk event store error: {message}"),
+ Self::ListingDraft { message } => write!(f, "sdk listing draft error: {message}"),
+ Self::ListingMutation { message } => {
+ write!(f, "sdk listing mutation error: {message}")
+ }
Self::Outbox { message } => write!(f, "sdk outbox error: {message}"),
Self::RelayTransport { message } => {
write!(f, "sdk relay transport error: {message}")
@@ -98,6 +104,24 @@ impl From<radroots_event_store::RadrootsEventStoreError> for RadrootsSdkError {
}
#[cfg(feature = "runtime")]
+impl From<radroots_trade::listing::RadrootsListingDraftError> for RadrootsSdkError {
+ fn from(error: radroots_trade::listing::RadrootsListingDraftError) -> Self {
+ Self::ListingDraft {
+ message: error.to_string(),
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl From<radroots_trade::listing::RadrootsListingMutationError> for RadrootsSdkError {
+ fn from(error: radroots_trade::listing::RadrootsListingMutationError) -> Self {
+ Self::ListingMutation {
+ message: error.to_string(),
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
impl From<radroots_outbox::RadrootsOutboxError> for RadrootsSdkError {
fn from(error: radroots_outbox::RadrootsOutboxError) -> Self {
Self::Outbox {
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -24,6 +24,8 @@ pub mod farm;
#[cfg(feature = "identity-models")]
pub mod identity;
pub mod listing;
+#[cfg(feature = "runtime")]
+mod listings_runtime;
pub mod order;
#[cfg(feature = "runtime")]
mod product_clients;
@@ -68,6 +70,10 @@ pub use crate::error::{
RadrootsSdkError, RadrootsSdkPartialLocalMutationError, RadrootsSdkRecoveryAction,
};
#[cfg(feature = "runtime")]
+pub use crate::listings_runtime::{
+ ListingEnqueueReceipt, ListingPublishRequest, PreparedListingPublish,
+};
+#[cfg(feature = "runtime")]
pub use crate::product_clients::{ListingsClient, OrdersClient, SyncClient};
#[cfg(feature = "runtime")]
pub use crate::receipt::{RadrootsSdkEventReference, RadrootsSdkLocalMutationReceipt};
diff --git a/crates/sdk/src/listings_runtime.rs b/crates/sdk/src/listings_runtime.rs
@@ -0,0 +1,206 @@
+#[cfg(feature = "runtime")]
+use crate::{
+ ListingsClient, RadrootsSdkError, RadrootsSdkEventReference, RadrootsSdkLocalMutationReceipt,
+ RadrootsSdkRecoveryAction, RadrootsSdkTimestamp,
+};
+#[cfg(feature = "runtime")]
+use radroots_authority::{RadrootsActorContext, RadrootsEventSigner, sign_authorized_draft};
+#[cfg(feature = "runtime")]
+use radroots_event_store::RadrootsEventIngest;
+#[cfg(feature = "runtime")]
+use radroots_events::{
+ RadrootsNostrEvent,
+ draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent},
+ listing::RadrootsListing,
+};
+#[cfg(feature = "runtime")]
+use radroots_outbox::RadrootsOutboxSignedOperationInput;
+#[cfg(feature = "runtime")]
+use radroots_trade::listing::{
+ RadrootsCanonicalListingDraft, RadrootsListingDraftDocumentV1, RadrootsListingMutation,
+ build_listing_mutation_draft, canonicalize_listing_draft,
+};
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug)]
+pub struct ListingPublishRequest {
+ pub listing: RadrootsListing,
+ pub target_relays: Option<Vec<String>>,
+ pub idempotency_key: Option<String>,
+}
+
+#[cfg(feature = "runtime")]
+impl ListingPublishRequest {
+ pub fn new(listing: RadrootsListing) -> Self {
+ Self {
+ listing,
+ target_relays: None,
+ idempotency_key: None,
+ }
+ }
+
+ pub fn with_target_relays<I, S>(mut self, target_relays: I) -> Self
+ where
+ I: IntoIterator<Item = S>,
+ S: Into<String>,
+ {
+ self.target_relays = Some(target_relays.into_iter().map(Into::into).collect());
+ self
+ }
+
+ pub fn with_idempotency_key(mut self, idempotency_key: impl Into<String>) -> Self {
+ self.idempotency_key = Some(idempotency_key.into());
+ self
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PreparedListingPublish {
+ pub draft: RadrootsFrozenEventDraft,
+ pub listing_address: String,
+ pub created_at: RadrootsSdkTimestamp,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ListingEnqueueReceipt {
+ pub listing_address: String,
+ pub local: RadrootsSdkLocalMutationReceipt,
+}
+
+#[cfg(feature = "runtime")]
+impl<'sdk> ListingsClient<'sdk> {
+ pub fn prepare_publish(
+ &self,
+ actor: &RadrootsActorContext,
+ request: ListingPublishRequest,
+ ) -> Result<PreparedListingPublish, RadrootsSdkError> {
+ let created_at = self.sdk.now()?;
+ let created_at_nostr = created_at.try_into_nostr_created_at()?;
+ let canonical = canonical_listing_draft(actor, request.listing)?;
+ let mutation = RadrootsListingMutation::publish(canonical);
+ let listing_address = mutation.listing_addr()?.as_str().to_owned();
+ let draft = build_listing_mutation_draft(&mutation, created_at_nostr)?;
+ Ok(PreparedListingPublish {
+ draft,
+ listing_address,
+ created_at,
+ })
+ }
+
+ pub async fn enqueue_publish<S>(
+ &self,
+ actor: &RadrootsActorContext,
+ signer: &S,
+ request: ListingPublishRequest,
+ ) -> Result<ListingEnqueueReceipt, RadrootsSdkError>
+ where
+ S: RadrootsEventSigner + ?Sized,
+ {
+ let target_relays = self.resolved_target_relays(&request);
+ if target_relays.is_empty() {
+ return Err(RadrootsSdkError::Outbox {
+ message: "listing enqueue requires at least one target relay".to_owned(),
+ });
+ }
+ let idempotency_key = request.idempotency_key.clone();
+ let prepared = self.prepare_publish(actor, request)?;
+ let signed_event = sign_authorized_draft(actor, signer, &prepared.draft)?;
+ let observed_at_ms = i64::from(prepared.draft.created_at) * 1_000;
+ let event = event_from_signed(&signed_event);
+ let ingest = RadrootsEventIngest::new(event, observed_at_ms)
+ .with_raw_json(signed_event.raw_json.clone());
+ let ingest_receipt = self.sdk._event_store.ingest_event(ingest).await?;
+ let outbox_input = signed_outbox_input(
+ &prepared,
+ signed_event.clone(),
+ target_relays,
+ idempotency_key,
+ ingest_receipt.inserted,
+ observed_at_ms,
+ );
+ let outbox_receipt = self
+ .sdk
+ ._outbox
+ .enqueue_signed_operation(outbox_input)
+ .await
+ .map_err(|_| {
+ RadrootsSdkError::partial_local_mutation(
+ true,
+ false,
+ RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey,
+ )
+ })?;
+ Ok(ListingEnqueueReceipt {
+ listing_address: prepared.listing_address,
+ local: RadrootsSdkLocalMutationReceipt {
+ event: RadrootsSdkEventReference {
+ event_id: signed_event.id,
+ pubkey: signed_event.pubkey,
+ kind: signed_event.kind,
+ created_at: signed_event.created_at,
+ },
+ stored: true,
+ queued: true,
+ outbox_event_id: Some(outbox_receipt.outbox_event_id),
+ idempotency_key_digest_prefix: Some(
+ outbox_receipt.idempotency_digest.chars().take(12).collect(),
+ ),
+ },
+ })
+ }
+
+ fn resolved_target_relays(&self, request: &ListingPublishRequest) -> Vec<String> {
+ request
+ .target_relays
+ .clone()
+ .unwrap_or_else(|| self.sdk.relay_urls().to_vec())
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn canonical_listing_draft(
+ actor: &RadrootsActorContext,
+ listing: RadrootsListing,
+) -> Result<RadrootsCanonicalListingDraft, RadrootsSdkError> {
+ let document = RadrootsListingDraftDocumentV1::new(listing);
+ canonicalize_listing_draft(actor, document).map_err(Into::into)
+}
+
+#[cfg(feature = "runtime")]
+fn signed_outbox_input(
+ prepared: &PreparedListingPublish,
+ signed_event: RadrootsSignedNostrEvent,
+ target_relays: Vec<String>,
+ idempotency_key: Option<String>,
+ event_store_inserted: bool,
+ observed_at_ms: i64,
+) -> RadrootsOutboxSignedOperationInput {
+ let input = RadrootsOutboxSignedOperationInput::new(
+ "listing.publish.v1",
+ prepared.draft.clone(),
+ signed_event,
+ target_relays,
+ event_store_inserted,
+ observed_at_ms,
+ observed_at_ms,
+ );
+ match idempotency_key {
+ Some(idempotency_key) => input.with_idempotency_key(idempotency_key),
+ None => input,
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn event_from_signed(signed_event: &RadrootsSignedNostrEvent) -> RadrootsNostrEvent {
+ RadrootsNostrEvent {
+ id: signed_event.id.clone(),
+ author: signed_event.pubkey.clone(),
+ created_at: signed_event.created_at,
+ kind: signed_event.kind,
+ tags: signed_event.tags.clone(),
+ content: signed_event.content.clone(),
+ sig: signed_event.sig.clone(),
+ }
+}
diff --git a/crates/sdk/src/product_clients.rs b/crates/sdk/src/product_clients.rs
@@ -6,13 +6,13 @@ use core::marker::PhantomData;
#[cfg(feature = "runtime")]
#[derive(Clone, Copy)]
pub struct ListingsClient<'sdk> {
- _sdk: PhantomData<&'sdk RadrootsSdk>,
+ pub(crate) sdk: &'sdk RadrootsSdk,
}
#[cfg(feature = "runtime")]
impl<'sdk> ListingsClient<'sdk> {
- pub(crate) fn new(_sdk: &'sdk RadrootsSdk) -> Self {
- Self { _sdk: PhantomData }
+ pub(crate) fn new(sdk: &'sdk RadrootsSdk) -> Self {
+ Self { sdk }
}
}
diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs
@@ -0,0 +1,283 @@
+#![cfg(feature = "runtime")]
+
+use radroots_authority::{
+ RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity,
+};
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+use radroots_event_store::RadrootsEventStore;
+use radroots_events::{
+ contract::RadrootsActorRole,
+ draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent, RadrootsSignedNostrEventParts},
+ farm::RadrootsFarmRef,
+ ids::{RadrootsDTag, RadrootsInventoryBinId},
+ kinds::KIND_LISTING,
+ listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct},
+};
+use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState};
+use radroots_sdk::{
+ ListingPublishRequest, RadrootsSdk, RadrootsSdkError, RadrootsSdkRecoveryAction,
+ RadrootsSdkTimestamp,
+};
+
+const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
+const LISTING_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ";
+const LISTING_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg";
+const LISTING_C_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAw";
+const LISTING_D_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABA";
+const LISTING_E_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABQ";
+const RELAY: &str = "wss://relay.example.com";
+
+#[derive(Clone)]
+struct FixtureSigner {
+ identity: RadrootsSignerIdentity,
+}
+
+impl FixtureSigner {
+ fn new(pubkey: &str) -> Self {
+ Self {
+ identity: RadrootsSignerIdentity::new(pubkey).expect("identity"),
+ }
+ }
+}
+
+impl RadrootsEventSigner for FixtureSigner {
+ fn pubkey(&self) -> &radroots_events::ids::RadrootsPublicKey {
+ self.identity.pubkey()
+ }
+
+ fn sign_frozen_draft(
+ &self,
+ draft: &RadrootsFrozenEventDraft,
+ ) -> Result<RadrootsSignedNostrEvent, RadrootsSignerError> {
+ if self.pubkey().as_str() != draft.expected_pubkey.as_str() {
+ return Err(RadrootsSignerError::SigningFailed {
+ message: "wrong fixture signer".to_owned(),
+ });
+ }
+ let sig = "f".repeat(128);
+ let raw_json = serde_json::json!({
+ "id": draft.expected_event_id,
+ "pubkey": self.pubkey().as_str(),
+ "created_at": draft.created_at,
+ "kind": draft.kind,
+ "tags": draft.tags,
+ "content": draft.content,
+ "sig": sig,
+ })
+ .to_string();
+ RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
+ id: draft.expected_event_id.clone(),
+ pubkey: self.pubkey().as_str().to_owned(),
+ created_at: draft.created_at,
+ kind: draft.kind,
+ tags: draft.tags.clone(),
+ content: draft.content.clone(),
+ sig,
+ raw_json,
+ })
+ .map_err(|error| RadrootsSignerError::SigningFailed {
+ message: error.to_string(),
+ })
+ }
+}
+
+fn actor() -> RadrootsActorContext {
+ RadrootsActorContext::test(SELLER, [RadrootsActorRole::Seller]).expect("actor")
+}
+
+fn listing(d_tag: &str, title: &str) -> RadrootsListing {
+ RadrootsListing {
+ d_tag: RadrootsDTag::parse(d_tag).expect("d tag"),
+ published_at: None,
+ farm: RadrootsFarmRef {
+ pubkey: SELLER.to_owned(),
+ d_tag: FARM_D_TAG.to_owned(),
+ },
+ product: RadrootsListingProduct {
+ key: "coffee".to_owned(),
+ title: title.to_owned(),
+ category: "coffee".to_owned(),
+ summary: Some("Single origin coffee".to_owned()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"),
+ bins: vec![RadrootsListingBin {
+ bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"),
+ 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: None,
+ availability: None,
+ delivery_method: None,
+ location: None,
+ images: None,
+ }
+}
+
+async fn directory_sdk() -> (tempfile::TempDir, RadrootsSdk) {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let sdk = RadrootsSdk::builder()
+ .directory_storage(tempdir.path().join("sdk"))
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
+ .relay_url(RELAY)
+ .build()
+ .await
+ .expect("sdk");
+ (tempdir, sdk)
+}
+
+#[tokio::test]
+async fn prepare_publish_is_side_effect_free() {
+ let (_tempdir, sdk) = directory_sdk().await;
+ let request = ListingPublishRequest::new(listing(LISTING_A_D_TAG, "Coffee"));
+ let prepared = sdk
+ .listings()
+ .prepare_publish(&actor(), request)
+ .expect("prepared");
+
+ assert_eq!(prepared.draft.kind, KIND_LISTING);
+ assert_eq!(prepared.created_at.unix_seconds(), 1_700_000_000);
+
+ let paths = sdk.storage_paths().expect("paths");
+ let event_store = RadrootsEventStore::open_file(&paths.event_store_path)
+ .await
+ .expect("event store");
+ assert!(
+ event_store
+ .get_event(prepared.draft.expected_event_id.as_str())
+ .await
+ .expect("event lookup")
+ .is_none()
+ );
+ let outbox = RadrootsOutbox::open_file(&paths.outbox_path)
+ .await
+ .expect("outbox");
+ assert!(
+ outbox
+ .claim_next_ready_event("worker", "claim", 2_000, 1_700_000_000_000)
+ .await
+ .expect("claim")
+ .is_none()
+ );
+}
+
+#[tokio::test]
+async fn enqueue_publish_stores_event_and_queues_signed_outbox_without_publish() {
+ let (_tempdir, sdk) = directory_sdk().await;
+ let request = ListingPublishRequest::new(listing(LISTING_B_D_TAG, "Coffee"))
+ .with_idempotency_key("idem-b");
+ let prepared = sdk
+ .listings()
+ .prepare_publish(&actor(), request.clone())
+ .expect("prepared");
+ let receipt = sdk
+ .listings()
+ .enqueue_publish(&actor(), &FixtureSigner::new(SELLER), request)
+ .await
+ .expect("enqueue");
+
+ assert_eq!(
+ receipt.local.event.event_id,
+ prepared.draft.expected_event_id
+ );
+ assert_eq!(receipt.local.event.kind, KIND_LISTING);
+ assert!(receipt.local.stored);
+ assert!(receipt.local.queued);
+ assert!(receipt.local.idempotency_key_digest_prefix.is_some());
+
+ let paths = sdk.storage_paths().expect("paths");
+ let event_store = RadrootsEventStore::open_file(&paths.event_store_path)
+ .await
+ .expect("event store");
+ assert!(
+ event_store
+ .get_event(receipt.local.event.event_id.as_str())
+ .await
+ .expect("event lookup")
+ .is_some()
+ );
+
+ let outbox = RadrootsOutbox::open_file(&paths.outbox_path)
+ .await
+ .expect("outbox");
+ let outbox_event = outbox
+ .get_event(receipt.local.outbox_event_id.expect("outbox event"))
+ .await
+ .expect("outbox event")
+ .expect("outbox event");
+ assert_eq!(outbox_event.state, RadrootsOutboxEventState::Signed);
+ assert!(outbox_event.signed_event.is_some());
+}
+
+#[tokio::test]
+async fn enqueue_publish_returns_sanitized_signer_errors() {
+ let (_tempdir, sdk) = directory_sdk().await;
+ let request = ListingPublishRequest::new(listing(LISTING_C_D_TAG, "Coffee"));
+ let error = sdk
+ .listings()
+ .enqueue_publish(&actor(), &FixtureSigner::new(OTHER), request)
+ .await
+ .expect_err("signer error");
+ let message = error.to_string();
+
+ assert!(matches!(error, RadrootsSdkError::Authority { .. }));
+ assert!(!message.contains("raw"));
+ assert!(!message.contains("ffff"));
+}
+
+#[tokio::test]
+async fn enqueue_publish_reports_partial_local_mutation_after_outbox_conflict() {
+ let (_tempdir, sdk) = directory_sdk().await;
+ let first = ListingPublishRequest::new(listing(LISTING_D_D_TAG, "Coffee"))
+ .with_idempotency_key("idem-d");
+ sdk.listings()
+ .enqueue_publish(&actor(), &FixtureSigner::new(SELLER), first)
+ .await
+ .expect("first enqueue");
+
+ let second = ListingPublishRequest::new(listing(LISTING_E_D_TAG, "Changed"))
+ .with_idempotency_key("idem-d");
+ let error = sdk
+ .listings()
+ .enqueue_publish(&actor(), &FixtureSigner::new(SELLER), second)
+ .await
+ .expect_err("partial");
+
+ assert!(matches!(
+ error,
+ RadrootsSdkError::PartialLocalMutation(ref partial)
+ if partial.stored
+ && !partial.queued
+ && partial.recovery == RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey
+ ));
+ assert!(!error.to_string().contains("idem-d"));
+}