commit 7b48b68af0eaa118340c4e1ef128e4bc2972f06b
parent ffe16540eed12831ee3b62b8a8f1abd6ff2f8682
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 11:31:43 +0000
sdk: refactor listing operations
- freeze publish and update plans through canonical trade and event contracts
- delegate durable listing commits to shared sync with native receipts
- preserve privacy transport idempotency and cancellation boundaries
- remove the retired duplicate listing runtime and tests
Diffstat:
6 files changed, 639 insertions(+), 2032 deletions(-)
diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs
@@ -245,6 +245,12 @@ impl Client {
Ok(self.sync()?.map(crate::farm::Operations::new))
}
+ /// Returns listing commit operations when canonical synchronization is configured.
+ #[cfg(feature = "sync")]
+ pub fn listing(&self) -> Result<Option<crate::listing::Operations<'_>>> {
+ Ok(self.sync()?.map(crate::listing::Operations::new))
+ }
+
/// Returns whether explicit close completed successfully or reached the
/// lower storage commit point.
#[must_use]
diff --git a/crates/sdk/src/listing.rs b/crates/sdk/src/listing.rs
@@ -1 +1,596 @@
-//! Listing product operations.
+//! Side-effect-free listing planning and canonical durable enqueue operations.
+
+use std::{error, fmt};
+
+use radroots_event::{EventDraft, contract::AuthorRole, id::ClassifiedListingAddress};
+use radroots_signing::Actor;
+use radroots_trade::operational_listing::{
+ RadrootsOperationalListingEditDocumentV1, RadrootsOperationalListingEditError,
+ RadrootsOperationalListingLifecycleState, RadrootsOperationalListingMutation,
+ RadrootsOperationalListingMutationError, build_operational_listing_mutation_draft,
+ canonicalize_operational_listing_edit,
+};
+
+/// Supported public listing mutation intent.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[non_exhaustive]
+pub enum Action {
+ /// Publish the first public version of a listing.
+ Publish,
+ /// Replace an existing addressable listing with a new public version.
+ Update,
+}
+
+/// Pure inputs for one frozen public listing plan.
+#[derive(Clone, Debug)]
+pub struct PrepareRequest {
+ actor: Actor,
+ document: RadrootsOperationalListingEditDocumentV1,
+ action: Action,
+ created_at_unix: u64,
+}
+
+impl PrepareRequest {
+ /// Creates a public listing publication request.
+ #[must_use]
+ pub const fn publish(
+ actor: Actor,
+ document: RadrootsOperationalListingEditDocumentV1,
+ created_at_unix: u64,
+ ) -> Self {
+ Self {
+ actor,
+ document,
+ action: Action::Publish,
+ created_at_unix,
+ }
+ }
+
+ /// Creates a public listing replacement request.
+ #[must_use]
+ pub const fn update(
+ actor: Actor,
+ document: RadrootsOperationalListingEditDocumentV1,
+ created_at_unix: u64,
+ ) -> Self {
+ Self {
+ actor,
+ document,
+ action: Action::Update,
+ created_at_unix,
+ }
+ }
+}
+
+/// Frozen, replay-stable public listing mutation plan.
+#[derive(Clone, Debug)]
+pub struct Plan {
+ actor: Actor,
+ action: Action,
+ address: ClassifiedListingAddress,
+ lifecycle: RadrootsOperationalListingLifecycleState,
+ draft: EventDraft,
+}
+
+impl Plan {
+ /// Returns the exact authorized actor carried into signing.
+ #[must_use]
+ pub const fn actor(&self) -> &Actor {
+ &self.actor
+ }
+
+ /// Returns the requested listing mutation intent.
+ #[must_use]
+ pub const fn action(&self) -> Action {
+ self.action
+ }
+
+ /// Returns the canonical addressable listing identity.
+ #[must_use]
+ pub const fn address(&self) -> &ClassifiedListingAddress {
+ &self.address
+ }
+
+ /// Returns the lower-owned lifecycle state resulting from the mutation.
+ #[must_use]
+ pub const fn lifecycle(&self) -> RadrootsOperationalListingLifecycleState {
+ self.lifecycle
+ }
+
+ /// Returns the frozen canonical event draft.
+ #[must_use]
+ pub const fn draft(&self) -> &EventDraft {
+ &self.draft
+ }
+}
+
+/// Listing plan validation stage.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[non_exhaustive]
+pub enum PrepareErrorKind {
+ /// The actor does not claim the seller author role.
+ UnauthorizedActor,
+ /// The lower trade boundary rejected the untrusted edit document.
+ Edit,
+ /// The lower trade/event boundary rejected mutation preparation.
+ Mutation,
+}
+
+/// One secret-safe listing planning failure retaining its lower source.
+pub struct PrepareError {
+ kind: PrepareErrorKind,
+ source: Option<Box<dyn error::Error + Send + Sync>>,
+}
+
+impl PrepareError {
+ /// Returns the stable client-level planning stage.
+ #[must_use]
+ pub const fn kind(&self) -> PrepareErrorKind {
+ self.kind
+ }
+
+ fn unauthorized_actor() -> Self {
+ Self {
+ kind: PrepareErrorKind::UnauthorizedActor,
+ source: None,
+ }
+ }
+
+ fn edit(source: RadrootsOperationalListingEditError) -> Self {
+ Self::with_source(PrepareErrorKind::Edit, source)
+ }
+
+ fn mutation(source: RadrootsOperationalListingMutationError) -> Self {
+ Self::with_source(PrepareErrorKind::Mutation, source)
+ }
+
+ fn with_source(
+ kind: PrepareErrorKind,
+ source: impl error::Error + Send + Sync + 'static,
+ ) -> Self {
+ Self {
+ kind,
+ source: Some(Box::new(source)),
+ }
+ }
+}
+
+impl fmt::Display for PrepareError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self.kind {
+ PrepareErrorKind::UnauthorizedActor => "listing actor is not authorized",
+ PrepareErrorKind::Edit => "listing edit is invalid",
+ PrepareErrorKind::Mutation => "listing mutation is invalid",
+ })
+ }
+}
+
+impl fmt::Debug for PrepareError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("PrepareError")
+ .field("kind", &self.kind)
+ .finish_non_exhaustive()
+ }
+}
+
+impl error::Error for PrepareError {
+ fn source(&self) -> Option<&(dyn error::Error + 'static)> {
+ self.source
+ .as_deref()
+ .map(|source| source as &(dyn error::Error + 'static))
+ }
+}
+
+/// Validates and freezes one listing mutation without storage, signing, or network work.
+///
+/// The canonical public model permits only coarse public locality. Exact
+/// coordinates and other private artifacts are deliberately absent; hosts
+/// persist those through `radroots_storage::private_artifact::PrivateArtifactStore`.
+pub fn prepare(request: PrepareRequest) -> Result<Plan, PrepareError> {
+ if !request.actor.satisfies(AuthorRole::Seller) {
+ return Err(PrepareError::unauthorized_actor());
+ }
+ let canonical =
+ canonicalize_operational_listing_edit(request.actor.public_key(), request.document)
+ .map_err(PrepareError::edit)?;
+ let address = canonical.public_listing_addr().clone();
+ let mutation = match request.action {
+ Action::Publish => RadrootsOperationalListingMutation::publish(canonical),
+ Action::Update => RadrootsOperationalListingMutation::update(canonical),
+ };
+ let lifecycle = mutation.lifecycle_state().map_err(PrepareError::mutation)?;
+ let draft = build_operational_listing_mutation_draft(&mutation, request.created_at_unix)
+ .map_err(PrepareError::mutation)?;
+ Ok(Plan {
+ actor: request.actor,
+ action: request.action,
+ address,
+ lifecycle,
+ draft,
+ })
+}
+
+#[cfg(feature = "sync")]
+use radroots_signing::request::CancellationPolicy;
+#[cfg(feature = "sync")]
+use radroots_storage::journal::IdempotencyKey;
+#[cfg(feature = "sync")]
+use radroots_sync::{
+ PushReceipt,
+ policy::{Error as SyncError, SyncId},
+};
+
+/// Explicit commit inputs for one prepared public listing mutation.
+#[cfg(feature = "sync")]
+#[derive(Clone, Debug)]
+pub struct EnqueueRequest {
+ operation_id: SyncId,
+ idempotency_key: IdempotencyKey,
+ plan: Plan,
+ profile: crate::transport::Profile,
+ cancellation: CancellationPolicy,
+}
+
+#[cfg(feature = "sync")]
+impl EnqueueRequest {
+ /// Creates an enqueue request whose transport selection has no fallback.
+ #[must_use]
+ pub const fn new(
+ operation_id: SyncId,
+ idempotency_key: IdempotencyKey,
+ plan: Plan,
+ profile: crate::transport::Profile,
+ cancellation: CancellationPolicy,
+ ) -> Self {
+ Self {
+ operation_id,
+ idempotency_key,
+ plan,
+ profile,
+ cancellation,
+ }
+ }
+}
+
+/// Borrowed listing commit operations over the canonical sync engine.
+#[cfg(feature = "sync")]
+#[derive(Clone, Copy, Debug)]
+pub struct Operations<'a> {
+ sync: crate::sync::Operations<'a>,
+}
+
+#[cfg(feature = "sync")]
+impl<'a> Operations<'a> {
+ pub(crate) const fn new(sync: crate::sync::Operations<'a>) -> Self {
+ Self { sync }
+ }
+
+ /// Signs and atomically enqueues a prepared public listing mutation.
+ pub async fn enqueue(&self, request: EnqueueRequest) -> Result<PushReceipt, SyncError> {
+ let targets = request
+ .profile
+ .targets()
+ .cloned()
+ .ok_or(SyncError::InvalidPushRequest)?;
+ let satisfaction = request
+ .profile
+ .satisfaction()
+ .cloned()
+ .ok_or(SyncError::InvalidPushRequest)?;
+ self.sync
+ .sign_and_enqueue(radroots_sync::PushRequest::new(
+ request.operation_id,
+ request.idempotency_key,
+ request.plan.actor,
+ request.plan.draft,
+ targets,
+ satisfaction,
+ request.cancellation,
+ )?)
+ .await
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit};
+ use radroots_event::{
+ envelope::kind::KIND_CLASSIFIED_LISTING,
+ farm::FarmRef,
+ id::{DTag, InventoryBinId},
+ listing::operational::{
+ OperationalListing, OperationalListingAvailability, OperationalListingBin,
+ OperationalListingDeliveryMethod, OperationalListingProduct,
+ OperationalListingPublicLocation, OperationalListingStatus,
+ },
+ };
+ use radroots_signing::actor::ActorSource;
+
+ use super::*;
+
+ const PUBLIC_KEY: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
+
+ fn actor(role: AuthorRole) -> Actor {
+ Actor::from_public_key_hex(PUBLIC_KEY, ActorSource::ExplicitPublicKey, [role])
+ .expect("actor")
+ }
+
+ fn listing(seller: &str) -> OperationalListing {
+ OperationalListing {
+ d_tag: DTag::parse("AAAAAAAAAAAAAAAAAAAAAg").expect("d tag"),
+ published_at: None,
+ farm: FarmRef {
+ pubkey: seller.to_owned(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(),
+ },
+ product: OperationalListingProduct {
+ key: "coffee".to_owned(),
+ title: "Coffee".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: InventoryBinId::parse("bin-1").expect("bin id"),
+ bins: vec![OperationalListingBin {
+ bin_id: InventoryBinId::parse("bin-1").expect("bin id"),
+ quantity: Quantity::try_new(Decimal::from(1000_u32), Unit::MassG)
+ .expect("quantity"),
+ price_per_canonical_unit: QuantityPrice::try_new(
+ Money::try_new(Decimal::from(20_u32), Currency::USD).expect("money"),
+ Quantity::try_new(Decimal::from(1_u32), Unit::MassG).expect("unit"),
+ )
+ .expect("price"),
+ 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: Some(Decimal::from(5_u32)),
+ availability: Some(OperationalListingAvailability::Status {
+ status: OperationalListingStatus::Active,
+ }),
+ delivery_method: Some(OperationalListingDeliveryMethod::Pickup),
+ location: Some(OperationalListingPublicLocation {
+ primary: "Santa Cruz, California".to_owned(),
+ city: Some("Santa Cruz".to_owned()),
+ region: Some("California".to_owned()),
+ country: Some("US".to_owned()),
+ geohash: "9q8yy".to_owned(),
+ }),
+ images: None,
+ }
+ }
+
+ fn document(seller: &str) -> RadrootsOperationalListingEditDocumentV1 {
+ RadrootsOperationalListingEditDocumentV1::new(listing(seller))
+ }
+
+ #[test]
+ fn prepare_publish_and_update_are_pure_canonical_and_privacy_bounded() {
+ let publish_request = PrepareRequest::publish(
+ actor(AuthorRole::Seller),
+ document(PUBLIC_KEY),
+ 1_800_000_000,
+ );
+ let first = prepare(publish_request.clone()).expect("publish plan");
+ let replay = prepare(publish_request).expect("replayed plan");
+ let update = prepare(PrepareRequest::update(
+ actor(AuthorRole::Seller),
+ document(PUBLIC_KEY),
+ 1_800_000_001,
+ ))
+ .expect("update plan");
+
+ assert_eq!(first.action(), Action::Publish);
+ assert_eq!(update.action(), Action::Update);
+ assert_eq!(
+ first.lifecycle(),
+ RadrootsOperationalListingLifecycleState::Published
+ );
+ assert_eq!(
+ update.lifecycle(),
+ RadrootsOperationalListingLifecycleState::Published
+ );
+ assert_eq!(first.address(), replay.address());
+ assert_eq!(first.draft(), replay.draft());
+ assert_eq!(first.address(), update.address());
+ assert_eq!(first.draft().kind_u32(), KIND_CLASSIFIED_LISTING);
+ assert_eq!(first.draft().created_at_u64(), 1_800_000_000);
+ assert!(!first.draft().content().contains("latitude"));
+ assert!(!first.draft().content().contains("longitude"));
+ }
+
+ #[test]
+ fn prepare_maps_authorization_and_lower_validation_classes_once() {
+ let unauthorized = prepare(PrepareRequest::publish(
+ actor(AuthorRole::Buyer),
+ document(PUBLIC_KEY),
+ 1_800_000_000,
+ ))
+ .expect_err("unauthorized");
+ assert_eq!(unauthorized.kind(), PrepareErrorKind::UnauthorizedActor);
+ assert!(std::error::Error::source(&unauthorized).is_none());
+
+ let mut invalid = listing(PUBLIC_KEY);
+ invalid.product.title.clear();
+ let edit = prepare(PrepareRequest::publish(
+ actor(AuthorRole::Seller),
+ RadrootsOperationalListingEditDocumentV1::new(invalid),
+ 1_800_000_000,
+ ))
+ .expect_err("invalid listing");
+ assert_eq!(edit.kind(), PrepareErrorKind::Edit);
+ assert!(std::error::Error::source(&edit).is_some());
+ assert_eq!(edit.to_string(), "listing edit is invalid");
+ assert!(!format!("{edit:?}").contains("title"));
+
+ let mismatch = prepare(PrepareRequest::update(
+ actor(AuthorRole::Seller),
+ document("8f"),
+ 1_800_000_000,
+ ))
+ .expect_err("invalid seller identity");
+ assert_eq!(mismatch.kind(), PrepareErrorKind::Edit);
+ }
+
+ #[cfg(all(feature = "sync", feature = "memory", feature = "local-signing"))]
+ mod enqueue {
+ use std::sync::{
+ Arc,
+ atomic::{AtomicU8, Ordering},
+ };
+
+ use radroots_storage::{
+ Outbox, event::SourceGeneration, journal::IdempotencyKey, memory::MemoryStorage,
+ };
+ use radroots_sync::{
+ Engine,
+ policy::{Clock, DeadlinePolicy, Error, IdSource, OperationKind, SyncId, SyncStorage},
+ };
+ use radroots_transport::{
+ DeliveryReceipt, DeliveryRequest, Error as TransportError, EventSink, SinkStatus,
+ Target, TargetSet, TransportId,
+ capability::{Availability, Maturity, SinkCapabilities},
+ policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy},
+ };
+
+ use super::*;
+ use crate::{ClientBuilder, transport::Profile};
+
+ struct FixedClock;
+ struct SequenceIds(AtomicU8);
+ struct NoopSink;
+
+ impl Clock for FixedClock {
+ fn now_unix_ms(&self) -> Result<u64, Error> {
+ Ok(2_000_000_000_000)
+ }
+ }
+ impl IdSource for SequenceIds {
+ fn next_id(&self, _operation: OperationKind) -> Result<SyncId, Error> {
+ SyncId::new([self.0.fetch_add(1, Ordering::Relaxed); 16])
+ }
+ }
+ impl EventSink for NoopSink {
+ fn status(
+ &self,
+ ) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, TransportError>> {
+ Box::pin(async {
+ Ok(SinkStatus::new(
+ TransportId::NOSTR,
+ true,
+ Maturity::Stable,
+ Availability::Available,
+ SinkCapabilities::DELIVER,
+ "ready",
+ ))
+ })
+ }
+ fn deliver(
+ &self,
+ _request: DeliveryRequest,
+ ) -> radroots_transport::BoxFuture<'_, Result<DeliveryReceipt, TransportError>>
+ {
+ Box::pin(async { Err(TransportError::UnsupportedOperation) })
+ }
+ }
+
+ #[tokio::test]
+ async fn enqueue_preserves_commit_cancellation_idempotency_and_transport_outcomes() {
+ let storage = Arc::new(MemoryStorage::new(
+ SourceGeneration::new([5; 32]).expect("generation"),
+ ));
+ let signer =
+ Arc::new(radroots_nostr::signing::LocalSigner::generate().expect("local signer"));
+ let seller = Actor::new(
+ signer.public_key(),
+ ActorSource::ExplicitPublicKey,
+ [AuthorRole::Seller],
+ )
+ .expect("actor");
+ let plan = prepare(PrepareRequest::publish(
+ seller,
+ document(&signer.public_key().to_hex()),
+ 1_800_000_000,
+ ))
+ .expect("plan");
+ let targets = TargetSet::new(vec![
+ Target::nostr_relay("wss://listing.example").expect("target"),
+ ])
+ .expect("targets");
+ let satisfaction =
+ SatisfactionPolicy::new(SatisfactionClass::Delivered, TargetPolicy::all());
+ let profile = Profile::delivery(targets.clone(), satisfaction.clone())
+ .expect("transport profile");
+ let capability: Arc<dyn SyncStorage> = storage.clone();
+ let engine = Engine::builder(
+ capability,
+ Arc::new(FixedClock),
+ Arc::new(SequenceIds(AtomicU8::new(1))),
+ DeadlinePolicy::new(1_000, 1_000, 1_000).expect("deadlines"),
+ )
+ .sink(Arc::new(NoopSink))
+ .signer(signer)
+ .build()
+ .expect("engine");
+ let client = ClientBuilder::new()
+ .storage(storage.clone())
+ .sync_engine(engine)
+ .build()
+ .expect("client");
+ let operations = client.listing().expect("open").expect("listing operations");
+ let request = EnqueueRequest::new(
+ SyncId::new([9; 16]).expect("operation id"),
+ IdempotencyKey::parse("listing-publish-a").expect("idempotency key"),
+ plan,
+ profile,
+ CancellationPolicy::PreservePublishedRequest,
+ );
+
+ drop(operations.enqueue(request.clone()));
+ assert_eq!(
+ Outbox::status(storage.as_ref())
+ .await
+ .expect("outbox status")
+ .pending,
+ 0
+ );
+ let committed = operations
+ .enqueue(request.clone())
+ .await
+ .expect("committed enqueue");
+ assert!(!committed.is_replay());
+ assert_eq!(committed.outbox().request().target_set(), &targets);
+ assert_eq!(committed.outbox().request().satisfaction(), &satisfaction);
+ let replay = operations.enqueue(request).await.expect("replay");
+ assert!(replay.is_replay());
+ assert_eq!(replay.outbox().item_id(), committed.outbox().item_id());
+
+ let unavailable = EnqueueRequest::new(
+ SyncId::new([10; 16]).expect("operation id"),
+ IdempotencyKey::parse("listing-update-preview").expect("idempotency key"),
+ prepare(PrepareRequest::update(
+ actor(AuthorRole::Seller),
+ document(PUBLIC_KEY),
+ 1_800_000_001,
+ ))
+ .expect("plan"),
+ Profile::unavailable_preview(TransportId::RETICULUM),
+ CancellationPolicy::PreservePublishedRequest,
+ );
+ assert_eq!(
+ operations.enqueue(unavailable).await,
+ Err(Error::InvalidPushRequest)
+ );
+ }
+ }
+}
diff --git a/crates/sdk/src/listings_runtime.rs b/crates/sdk/src/listings_runtime.rs
@@ -1,430 +0,0 @@
-#[cfg(feature = "signer-adapters")]
-use crate::workflow_runtime::enqueue_configured_signed_workflow;
-#[cfg(feature = "runtime")]
-use crate::{
- ListingsClient, NostrRelayUrlPolicy, RadrootsSdkError, RadrootsSdkTimestamp,
- SatisfactionPolicy, SdkIdempotencyKey, TargetPolicy,
- workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow},
-};
-#[cfg(feature = "runtime")]
-use radroots_event::{
- contract::AuthorRole,
- draft::EventDraft,
- envelope::kind::KIND_CLASSIFIED_LISTING,
- id::{ClassifiedListingAddress, EventId},
- listing::operational::OperationalListing,
-};
-#[cfg(feature = "runtime")]
-use radroots_outbox::RadrootsOutboxEnqueueStatus;
-#[cfg(feature = "runtime")]
-use radroots_signing::{Actor, Signer};
-#[cfg(feature = "runtime")]
-use radroots_trade::operational_listing::{
- RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1,
- RadrootsOperationalListingMutation, build_operational_listing_mutation_draft,
- canonicalize_operational_listing_edit,
-};
-#[cfg(feature = "runtime")]
-pub const LISTING_PUBLISH_OPERATION_KIND: &str = "listing.publish.v1";
-
-#[cfg(feature = "runtime")]
-const OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID: &str = "radroots.operational_listing.published.v1";
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, serde::Serialize)]
-#[non_exhaustive]
-pub struct ListingPreparePublishRequest {
- #[serde(serialize_with = "crate::actor_json::serialize_actor_context")]
- pub actor: Actor,
- pub document: RadrootsOperationalListingEditDocumentV1,
- pub created_at: Option<RadrootsSdkTimestamp>,
-}
-
-#[cfg(feature = "runtime")]
-impl ListingPreparePublishRequest {
- pub fn new(actor: Actor, listing: OperationalListing) -> Self {
- Self {
- actor,
- document: RadrootsOperationalListingEditDocumentV1::new(listing),
- created_at: None,
- }
- }
-
- pub fn from_document(actor: Actor, document: RadrootsOperationalListingEditDocumentV1) -> Self {
- Self {
- actor,
- document,
- created_at: None,
- }
- }
-
- pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self {
- self.created_at = Some(created_at);
- self
- }
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, serde::Serialize)]
-#[non_exhaustive]
-pub struct ListingEnqueuePublishRequest {
- #[serde(serialize_with = "crate::actor_json::serialize_actor_context")]
- pub actor: Actor,
- pub document: RadrootsOperationalListingEditDocumentV1,
- pub target_policy: TargetPolicy,
- pub idempotency_key: Option<SdkIdempotencyKey>,
- pub created_at: Option<RadrootsSdkTimestamp>,
-}
-
-#[cfg(feature = "runtime")]
-impl ListingEnqueuePublishRequest {
- pub fn new(actor: Actor, listing: OperationalListing, target_policy: TargetPolicy) -> Self {
- Self::from_document(
- actor,
- RadrootsOperationalListingEditDocumentV1::new(listing),
- target_policy,
- )
- }
-
- pub fn from_document(
- actor: Actor,
- document: RadrootsOperationalListingEditDocumentV1,
- target_policy: TargetPolicy,
- ) -> Self {
- Self {
- actor,
- document,
- target_policy,
- idempotency_key: None,
- created_at: None,
- }
- }
-
- pub fn try_with_nostr_targets<I, S>(
- mut self,
- target_policy: I,
- policy: NostrRelayUrlPolicy,
- ) -> Result<Self, RadrootsSdkError>
- where
- I: IntoIterator<Item = S>,
- S: AsRef<str>,
- {
- self.target_policy = TargetPolicy::try_nostr_relays(target_policy, policy)?;
- Ok(self)
- }
-
- pub fn with_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self {
- self.idempotency_key = Some(idempotency_key);
- self
- }
-
- pub fn try_with_idempotency_key(
- mut self,
- idempotency_key: impl AsRef<str>,
- ) -> Result<Self, RadrootsSdkError> {
- self.idempotency_key = Some(SdkIdempotencyKey::new(idempotency_key)?);
- Ok(self)
- }
-
- pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self {
- self.created_at = Some(created_at);
- self
- }
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
-pub struct ListingPublishPlan {
- public_listing_addr: ClassifiedListingAddress,
- expected_event_id: EventId,
- frozen_draft: EventDraft,
- created_at: RadrootsSdkTimestamp,
-}
-
-#[cfg(feature = "runtime")]
-impl ListingPublishPlan {
- pub fn public_listing_addr(&self) -> &ClassifiedListingAddress {
- &self.public_listing_addr
- }
-
- pub fn expected_event_id(&self) -> &EventId {
- &self.expected_event_id
- }
-
- pub fn frozen_draft(&self) -> &EventDraft {
- &self.frozen_draft
- }
-
- pub fn created_at(&self) -> RadrootsSdkTimestamp {
- self.created_at
- }
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
-#[serde(rename_all = "snake_case")]
-#[non_exhaustive]
-pub enum SdkMutationState {
- StoredAndQueued,
- AlreadyQueued,
-}
-
-#[cfg(feature = "runtime")]
-impl From<RadrootsOutboxEnqueueStatus> for SdkMutationState {
- fn from(value: RadrootsOutboxEnqueueStatus) -> Self {
- match value {
- RadrootsOutboxEnqueueStatus::Inserted => Self::StoredAndQueued,
- RadrootsOutboxEnqueueStatus::Existing => Self::AlreadyQueued,
- }
- }
-}
-
-#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
-pub struct ListingEnqueueReceipt {
- pub public_listing_addr: ClassifiedListingAddress,
- pub expected_event_id: EventId,
- pub signed_event_id: EventId,
- pub local_event_seq: i64,
- pub outbox_operation_id: i64,
- pub outbox_event_id: i64,
- pub state: SdkMutationState,
- pub idempotency_digest_prefix: Option<String>,
-}
-
-#[cfg(feature = "runtime")]
-impl<'sdk> ListingsClient<'sdk> {
- pub fn prepare_publish(
- &self,
- request: ListingPreparePublishRequest,
- ) -> Result<ListingPublishPlan, RadrootsSdkError> {
- let created_at = self.resolved_created_at(request.created_at)?;
- listing_publish_plan(&request.actor, request.document, created_at)
- }
-
- #[cfg(feature = "signer-adapters")]
- pub async fn enqueue_publish(
- &self,
- request: ListingEnqueuePublishRequest,
- ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> {
- let ListingEnqueuePublishRequest {
- actor,
- document,
- target_policy,
- idempotency_key,
- created_at,
- } = request;
- let prepare_request = ListingPreparePublishRequest {
- actor: actor.clone(),
- document,
- created_at,
- };
- let plan = self.prepare_publish(prepare_request)?;
- self.enqueue_prepared_publish(&actor, plan, target_policy, idempotency_key)
- .await
- }
-
- pub async fn enqueue_publish_with_explicit_signer(
- &self,
- request: ListingEnqueuePublishRequest,
- signer: &dyn Signer,
- ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> {
- let ListingEnqueuePublishRequest {
- actor,
- document,
- target_policy,
- idempotency_key,
- created_at,
- } = request;
- let prepare_request = ListingPreparePublishRequest {
- actor: actor.clone(),
- document,
- created_at,
- };
- let plan = self.prepare_publish(prepare_request)?;
- self.enqueue_prepared_publish_with_explicit_signer(
- &actor,
- plan,
- target_policy,
- idempotency_key,
- signer,
- )
- .await
- }
-
- #[cfg(feature = "signer-adapters")]
- pub async fn enqueue_prepared_publish(
- &self,
- actor: &Actor,
- plan: ListingPublishPlan,
- target_policy: TargetPolicy,
- idempotency_key: Option<SdkIdempotencyKey>,
- ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> {
- let metadata = validate_listing_publish_plan(&plan)?;
- let enqueue = enqueue_configured_signed_workflow(
- self.sdk,
- SdkWorkflowEnqueueRequest {
- operation_kind: LISTING_PUBLISH_OPERATION_KIND,
- actor,
- frozen_draft: &plan.frozen_draft,
- target_policy,
- satisfaction_policy: SatisfactionPolicy::AllAccepted,
- idempotency_key,
- },
- )
- .await?;
- Ok(listing_enqueue_receipt(metadata, enqueue))
- }
-
- pub async fn enqueue_prepared_publish_with_explicit_signer(
- &self,
- actor: &Actor,
- plan: ListingPublishPlan,
- target_policy: TargetPolicy,
- idempotency_key: Option<SdkIdempotencyKey>,
- signer: &dyn Signer,
- ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> {
- let metadata = validate_listing_publish_plan(&plan)?;
- let enqueue = enqueue_signed_workflow(
- self.sdk,
- SdkWorkflowEnqueueRequest {
- operation_kind: LISTING_PUBLISH_OPERATION_KIND,
- actor,
- frozen_draft: &plan.frozen_draft,
- target_policy,
- satisfaction_policy: SatisfactionPolicy::AllAccepted,
- idempotency_key,
- },
- signer,
- )
- .await?;
- Ok(listing_enqueue_receipt(metadata, enqueue))
- }
-
- fn resolved_created_at(
- &self,
- created_at: Option<RadrootsSdkTimestamp>,
- ) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> {
- match created_at {
- Some(created_at) => Ok(created_at),
- None => self.sdk.now(),
- }
- }
-}
-
-#[cfg(feature = "runtime")]
-fn listing_enqueue_receipt(
- metadata: ValidatedListingPublishPlanMetadata,
- enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt,
-) -> ListingEnqueueReceipt {
- ListingEnqueueReceipt {
- public_listing_addr: metadata.public_listing_addr,
- expected_event_id: metadata.expected_event_id,
- signed_event_id: enqueue.signed_event_id,
- local_event_seq: enqueue.local_event_seq,
- outbox_operation_id: enqueue.outbox_operation_id,
- outbox_event_id: enqueue.outbox_event_id,
- state: enqueue.state.into(),
- idempotency_digest_prefix: Some(enqueue.idempotency_digest_prefix),
- }
-}
-
-#[cfg(feature = "runtime")]
-fn canonical_listing_edit(
- actor: &Actor,
- document: RadrootsOperationalListingEditDocumentV1,
-) -> Result<RadrootsOperationalListingCanonicalEdit, RadrootsSdkError> {
- if !actor.satisfies(AuthorRole::Seller) {
- return Err(RadrootsSdkError::UnauthorizedActor {
- operation: "listing.prepare_publish".to_owned(),
- reason: "missing role Seller".to_owned(),
- });
- }
- canonicalize_operational_listing_edit(actor.public_key(), document).map_err(Into::into)
-}
-
-#[cfg(feature = "runtime")]
-struct ValidatedListingPublishPlanMetadata {
- public_listing_addr: ClassifiedListingAddress,
- expected_event_id: EventId,
-}
-
-#[cfg(feature = "runtime")]
-fn validate_listing_publish_plan(
- plan: &ListingPublishPlan,
-) -> Result<ValidatedListingPublishPlanMetadata, RadrootsSdkError> {
- let invalid = |reason: &str| RadrootsSdkError::InvalidRequest {
- message: format!("invalid prepared listing publish plan: {reason}"),
- };
- plan.frozen_draft
- .validate_for_signing()
- .map_err(|_| invalid("frozen draft is invalid"))?;
- if plan.frozen_draft.contract_id() != OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID
- || plan.frozen_draft.kind_u32() != KIND_CLASSIFIED_LISTING
- {
- return Err(invalid(
- "contract or kind does not match Operational Listing publish",
- ));
- }
-
- let expected_event_id = EventId::parse(plan.frozen_draft.expected_event_id_hex())
- .expect("validated frozen draft has a typed event ID");
- if plan.expected_event_id != expected_event_id {
- return Err(invalid("expected event ID does not match frozen draft"));
- }
- if plan.created_at.unix_seconds() != plan.frozen_draft.created_at_u64() {
- return Err(invalid("created-at timestamp does not match frozen draft"));
- }
-
- let tags = plan.frozen_draft.tags_as_vec();
- let mut d_tags = tags
- .iter()
- .filter(|tag| tag.first().is_some_and(|value| value == "d"));
- let d_tag = d_tags
- .next()
- .and_then(|tag| tag.get(1))
- .ok_or_else(|| invalid("frozen draft is missing its listing identifier"))?;
- if d_tags.next().is_some() {
- return Err(invalid(
- "frozen draft contains duplicate listing identifiers",
- ));
- }
- let public_listing_addr = ClassifiedListingAddress::parse(format!(
- "{KIND_CLASSIFIED_LISTING}:{}:{d_tag}",
- plan.frozen_draft.expected_pubkey().to_hex()
- ))
- .map_err(|_| invalid("frozen draft listing address is invalid"))?;
- if plan.public_listing_addr != public_listing_addr {
- return Err(invalid("listing address does not match frozen draft"));
- }
- Ok(ValidatedListingPublishPlanMetadata {
- public_listing_addr,
- expected_event_id,
- })
-}
-
-#[cfg(feature = "runtime")]
-fn listing_publish_plan(
- actor: &Actor,
- document: RadrootsOperationalListingEditDocumentV1,
- created_at: RadrootsSdkTimestamp,
-) -> Result<ListingPublishPlan, RadrootsSdkError> {
- let created_at_nostr = created_at.try_into_nostr_created_at()?;
- let canonical = canonical_listing_edit(actor, document)?;
- let public_listing_addr = canonical.public_listing_addr().clone();
- let mutation = RadrootsOperationalListingMutation::publish(canonical);
- let frozen_draft =
- build_operational_listing_mutation_draft(&mutation, u64::from(created_at_nostr))?;
- let expected_event_id = EventId::parse(frozen_draft.expected_event_id_hex())
- .expect("frozen listing edit produces a valid event id");
- Ok(ListingPublishPlan {
- public_listing_addr,
- expected_event_id,
- frozen_draft,
- created_at,
- })
-}
-
-#[cfg(all(test, feature = "runtime", feature = "signer-adapters"))]
-#[path = "../tests/unit/listings_runtime_tests.rs"]
-mod tests;
diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs
@@ -1,1001 +0,0 @@
-#![cfg(feature = "runtime")]
-
-use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit};
-use radroots_event::{
- contract::AuthorRole,
- envelope::kind::KIND_CLASSIFIED_LISTING,
- farm::FarmRef,
- id::{DTag, InventoryBinId},
- listing::operational::{
- OperationalListing, OperationalListingAvailability, OperationalListingBin,
- OperationalListingDeliveryMethod, OperationalListingProduct,
- OperationalListingPublicLocation, OperationalListingStatus,
- },
-};
-use radroots_event_store::RadrootsEventStore;
-use radroots_outbox::{
- RadrootsOutbox, RadrootsOutboxDeliveryPlanStatus, RadrootsOutboxDeliveryTargetStatus,
- RadrootsOutboxEventState,
-};
-use radroots_sdk::{
- LISTING_PUBLISH_OPERATION_KIND, ListingEnqueuePublishRequest, ListingPreparePublishRequest,
- MultiTargetProfile, NostrProfile, NostrRelayUrlPolicy, PushOutboxEventState, PushOutboxRequest,
- PushOutboxTargetOutcomeKind, RadrootsClient, RadrootsSdkError, RadrootsSdkRecoveryAction,
- RadrootsSdkTimestamp, ReticulumProfile, SdkIdempotencyKey, SdkMutationState, TargetPolicy,
- TargetSet, TransportProfile,
-};
-use radroots_signing::{Actor, actor::ActorSource};
-use radroots_trade::operational_listing::RadrootsOperationalListingEditDocumentV1;
-use radroots_transport_nostr::{RadrootsMockRelayPublishAdapter, RadrootsNostrTransport};
-use sqlx::Row;
-
-#[path = "support/fixture_signer.rs"]
-mod fixture_signer;
-#[path = "support/serializer_failure.rs"]
-mod serializer_failure;
-
-use fixture_signer::{FixtureSigner, fixture_alice_pubkey, fixture_bob_pubkey};
-use serializer_failure::assert_struct_serialize_error_paths;
-
-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 LISTING_F_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABg";
-const LISTING_G_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABw";
-const LISTING_H_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAACA";
-const LISTING_I_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAACQ";
-const LISTING_J_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAACg";
-const LISTING_K_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAACw";
-const RELAY: &str = "wss://relay.example.com";
-const RELAY_B: &str = "wss://relay-b.example.com";
-
-fn seller_pubkey() -> &'static str {
- fixture_alice_pubkey()
-}
-
-fn other_pubkey() -> &'static str {
- fixture_bob_pubkey()
-}
-
-fn actor() -> Actor {
- Actor::from_public_key_hex(
- seller_pubkey(),
- ActorSource::ExplicitPublicKey,
- [AuthorRole::Seller],
- )
- .expect("actor")
-}
-
-fn non_seller_actor() -> Actor {
- Actor::from_public_key_hex(
- seller_pubkey(),
- ActorSource::ExplicitPublicKey,
- [AuthorRole::Buyer],
- )
- .expect("actor")
-}
-
-fn listing(d_tag: &str, title: &str) -> OperationalListing {
- OperationalListing {
- d_tag: DTag::parse(d_tag).expect("d tag"),
- published_at: None,
- farm: FarmRef {
- pubkey: seller_pubkey().to_owned(),
- d_tag: FARM_D_TAG.to_owned(),
- },
- product: OperationalListingProduct {
- 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: InventoryBinId::parse("bin-1").expect("bin id"),
- bins: vec![OperationalListingBin {
- bin_id: InventoryBinId::parse("bin-1").expect("bin id"),
- quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG)
- .expect("positive fixture quantity"),
- price_per_canonical_unit: QuantityPrice::try_new(
- Money::try_new(Decimal::from(20u32), Currency::USD)
- .expect("non-negative fixture money"),
- Quantity::try_new(Decimal::from(1u32), Unit::MassG)
- .expect("positive fixture pricing quantity"),
- )
- .expect("non-zero fixture pricing quantity"),
- 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: Some(Decimal::from(5u32)),
- availability: Some(OperationalListingAvailability::Status {
- status: OperationalListingStatus::Active,
- }),
- delivery_method: Some(OperationalListingDeliveryMethod::Pickup),
- location: Some(OperationalListingPublicLocation {
- primary: "Victoria".to_owned(),
- city: Some("Victoria".to_owned()),
- region: Some("British Columbia".to_owned()),
- country: Some("CA".to_owned()),
- geohash: "c287g".to_owned(),
- }),
- images: None,
- }
-}
-
-async fn directory_sdk() -> (tempfile::TempDir, RadrootsClient) {
- directory_sdk_with_relays(&[RELAY]).await
-}
-
-async fn directory_sdk_with_relays(relays: &[&str]) -> (tempfile::TempDir, RadrootsClient) {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let mut builder = RadrootsClient::builder()
- .directory_storage(tempdir.path().join("sdk"))
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000));
- if !relays.is_empty() {
- builder = builder.transport_profile(TransportProfile::nostr(
- NostrProfile::new(relays.iter().copied(), NostrRelayUrlPolicy::Public)
- .expect("Nostr profile"),
- ));
- }
- let sdk = builder.build().await.expect("sdk");
- (tempdir, sdk)
-}
-
-async fn multi_target_directory_sdk() -> (tempfile::TempDir, RadrootsClient) {
- let tempdir = tempfile::tempdir().expect("tempdir");
- let sdk = RadrootsClient::builder()
- .directory_storage(tempdir.path().join("sdk"))
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
- .transport_profile(TransportProfile::multi_target(MultiTargetProfile::new(
- NostrProfile::new([RELAY], NostrRelayUrlPolicy::Public).expect("Nostr profile"),
- ReticulumProfile::deferred_until_implemented(),
- )))
- .build()
- .await
- .expect("sdk");
- (tempdir, sdk)
-}
-
-#[tokio::test]
-async fn prepare_publish_is_side_effect_free() {
- let (_tempdir, sdk) = directory_sdk().await;
- let request = ListingPreparePublishRequest::new(actor(), listing(LISTING_A_D_TAG, "Coffee"));
- let prepared = sdk.listings().prepare_publish(request).expect("prepared");
-
- assert_eq!(prepared.frozen_draft().kind_u32(), KIND_CLASSIFIED_LISTING);
- assert_eq!(prepared.created_at().unix_seconds(), 1_700_000_000);
- assert_eq!(
- prepared.expected_event_id().to_hex(),
- prepared.frozen_draft().expected_event_id_hex()
- );
- assert_eq!(
- prepared.public_listing_addr().as_str(),
- format!(
- "{KIND_CLASSIFIED_LISTING}:{}:{LISTING_A_D_TAG}",
- seller_pubkey()
- )
- );
-
- let paths = sdk.storage_paths().expect("paths");
- let event_store = RadrootsEventStore::open_file(&paths.runtime_path)
- .await
- .expect("event store");
- assert!(
- event_store
- .raw_event(&prepared.expected_event_id().to_hex())
- .await
- .expect("event lookup")
- .is_none()
- );
- let outbox = RadrootsOutbox::open_file(&paths.runtime_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 prepare_publish_rejects_non_seller_actor() {
- let (_tempdir, sdk) = directory_sdk().await;
- let request =
- ListingPreparePublishRequest::new(non_seller_actor(), listing(LISTING_B_D_TAG, "Coffee"));
-
- let error = sdk
- .listings()
- .prepare_publish(request)
- .expect_err("non seller");
-
- assert!(matches!(error, RadrootsSdkError::UnauthorizedActor { .. }));
-}
-
-#[tokio::test]
-async fn enqueue_publish_stores_event_and_queues_signed_outbox_without_publish() {
- let (_tempdir, sdk) = directory_sdk().await;
- let request = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_B_D_TAG, "Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000224")
- .expect("idempotency key");
- let prepared = sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::new(
- actor(),
- listing(LISTING_B_D_TAG, "Coffee"),
- ))
- .expect("prepared");
- let receipt = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey()))
- .await
- .expect("enqueue");
-
- assert_eq!(&receipt.expected_event_id, prepared.expected_event_id());
- assert_eq!(receipt.signed_event_id, receipt.expected_event_id);
- assert_eq!(&receipt.public_listing_addr, prepared.public_listing_addr());
- assert_eq!(receipt.local_event_seq, 1);
- assert_eq!(receipt.outbox_operation_id, 1);
- assert_eq!(receipt.outbox_event_id, 1);
- assert_eq!(receipt.state, SdkMutationState::StoredAndQueued);
- assert!(receipt.idempotency_digest_prefix.is_some());
-
- let paths = sdk.storage_paths().expect("paths");
- let event_store = RadrootsEventStore::open_file(&paths.runtime_path)
- .await
- .expect("event store");
- assert!(
- event_store
- .valid_event(&receipt.signed_event_id.to_hex())
- .await
- .expect("event lookup")
- .is_some()
- );
-
- let outbox = RadrootsOutbox::open_file(&paths.runtime_path)
- .await
- .expect("outbox");
- let outbox_event = outbox
- .get_event(receipt.outbox_event_id)
- .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_default_profile_rejects_empty_transport_targets() {
- let (_tempdir, sdk) = directory_sdk_with_relays(&[]).await;
- let request = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_A_D_TAG, "Coffee"),
- TargetPolicy::default_profile(),
- );
-
- let error = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey()))
- .await
- .expect_err("empty transport profile");
-
- assert!(matches!(
- error,
- RadrootsSdkError::EmptyTransportTargets { operation }
- if operation == "publish transport profile"
- ));
-}
-
-#[tokio::test]
-async fn prepare_then_enqueue_prepared_uses_same_event_id() {
- let (_tempdir, sdk) = directory_sdk().await;
- let actor = actor();
- let prepared = sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::new(
- actor.clone(),
- listing(LISTING_G_D_TAG, "Coffee"),
- ))
- .expect("prepared");
- let receipt = sdk
- .listings()
- .enqueue_prepared_publish_with_explicit_signer(
- &actor,
- prepared.clone(),
- TargetPolicy::default_profile(),
- Some(
- SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000238")
- .expect("idempotency"),
- ),
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect("prepared enqueue");
-
- assert_eq!(&receipt.expected_event_id, prepared.expected_event_id());
- assert_eq!(&receipt.signed_event_id, prepared.expected_event_id());
-
- let paths = sdk.storage_paths().expect("paths");
- let event_store = RadrootsEventStore::open_file(&paths.runtime_path)
- .await
- .expect("event store");
- assert!(
- event_store
- .valid_event(&prepared.expected_event_id().to_hex())
- .await
- .expect("event lookup")
- .is_some()
- );
-
- let outbox = RadrootsOutbox::open_file(&paths.runtime_path)
- .await
- .expect("outbox");
- let outbox_event = outbox
- .get_event(receipt.outbox_event_id)
- .await
- .expect("outbox event")
- .expect("outbox event");
- assert_eq!(outbox_event.event_id, prepared.expected_event_id().to_hex());
-}
-
-#[tokio::test]
-async fn enqueue_receipt_debug_omits_signed_event_payload_material() {
- let (_tempdir, sdk) = directory_sdk().await;
- let request = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_A_D_TAG, "Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000225")
- .expect("idempotency key");
- let receipt = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey()))
- .await
- .expect("enqueue");
- let debug = format!("{receipt:?}");
-
- assert!(debug.contains("ListingEnqueueReceipt"));
- assert!(debug.contains("StoredAndQueued"));
- assert!(!debug.contains("01890f0e-6c00-7000-8000-000000000225"));
- assert!(!debug.contains("raw_json"));
- assert!(!debug.contains("\"tags\""));
- assert!(!debug.contains("\"content\""));
- assert!(!debug.contains(&"f".repeat(128)));
-}
-
-#[test]
-fn mutation_state_debug_uses_product_state_names() {
- assert_eq!(
- format!("{:?}", SdkMutationState::StoredAndQueued),
- "StoredAndQueued"
- );
- assert_eq!(
- format!("{:?}", SdkMutationState::AlreadyQueued),
- "AlreadyQueued"
- );
-}
-
-#[tokio::test]
-async fn listing_runtime_dtos_serialize_deterministically() {
- let (_tempdir, sdk) = directory_sdk().await;
- let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_123);
- let prepare_request = ListingPreparePublishRequest::from_document(
- actor(),
- RadrootsOperationalListingEditDocumentV1::new(listing(
- LISTING_A_D_TAG,
- "Serialized Coffee",
- )),
- )
- .with_created_at(created_at);
- let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json");
- assert_struct_serialize_error_paths(&prepare_request, 3);
-
- assert_eq!(prepare_json["actor"]["pubkey"], seller_pubkey());
- assert_eq!(
- prepare_json["actor"]["roles"],
- serde_json::json!(["seller"])
- );
- assert_eq!(prepare_json["actor"]["source"], "explicit_public_key");
- assert_eq!(prepare_json["created_at"], 1_700_000_123);
- assert_eq!(
- prepare_json["document"]["listing"]["product"]["title"],
- "Serialized Coffee"
- );
-
- let enqueue_request = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_B_D_TAG, "Queued Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_nostr_targets([RELAY, RELAY_B], NostrRelayUrlPolicy::Public)
- .expect("relay targets")
- .with_idempotency_key(
- SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000226").expect("idempotency"),
- )
- .with_created_at(created_at);
- let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json");
- assert_struct_serialize_error_paths(&enqueue_request, 5);
-
- assert_eq!(enqueue_json["target_policy"]["kind"], "explicit");
- assert_eq!(
- enqueue_json["target_policy"]["targets"],
- serde_json::json!([
- {
- "kind": "nostr",
- "uri": RELAY,
- "scope": null,
- "label": null,
- "fingerprint": "a1997ec4596596af6ffc65e6a30ab7cffa53ea71f524c1c86d64018b96d130af"
- },
- {
- "kind": "nostr",
- "uri": RELAY_B,
- "scope": null,
- "label": null,
- "fingerprint": "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05"
- }
- ])
- );
- assert_eq!(
- enqueue_json["target_policy"]["canonical_targets"],
- serde_json::json!([
- "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05",
- "a1997ec4596596af6ffc65e6a30ab7cffa53ea71f524c1c86d64018b96d130af"
- ])
- );
- assert_eq!(
- enqueue_json["idempotency_key"],
- serde_json::json!({ "value": "<redacted>", "len": 36 })
- );
- assert!(
- !enqueue_json
- .to_string()
- .contains("01890f0e-6c00-7000-8000-000000000226")
- );
-
- let try_key_request = ListingEnqueuePublishRequest::from_document(
- actor(),
- RadrootsOperationalListingEditDocumentV1::new(listing(LISTING_C_D_TAG, "Queued Coffee")),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000227")
- .expect("try idempotency key");
- assert_eq!(
- serde_json::to_value(&try_key_request).expect("try key request json")["idempotency_key"],
- serde_json::json!({ "value": "<redacted>", "len": 36 })
- );
-
- let receipt = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(enqueue_request, &FixtureSigner::new(seller_pubkey()))
- .await
- .expect("enqueue");
- let receipt_json = serde_json::to_value(&receipt).expect("receipt json");
-
- assert_eq!(receipt_json["state"], "stored_and_queued");
- assert_eq!(receipt_json["local_event_seq"], 1);
- assert!(receipt_json["idempotency_digest_prefix"].is_string());
-}
-
-#[tokio::test]
-async fn enqueue_publish_convenience_matches_prepare_plus_enqueue_prepared() {
- let (_prepared_tempdir, prepared_sdk) = directory_sdk().await;
- let prepared_actor = actor();
- let prepared_plan = prepared_sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::new(
- prepared_actor.clone(),
- listing(LISTING_H_D_TAG, "Coffee"),
- ))
- .expect("prepared plan");
- let prepared_receipt = prepared_sdk
- .listings()
- .enqueue_prepared_publish_with_explicit_signer(
- &prepared_actor,
- prepared_plan,
- TargetPolicy::default_profile(),
- Some(
- SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000239")
- .expect("idempotency"),
- ),
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect("prepared enqueue");
-
- let (_convenience_tempdir, convenience_sdk) = directory_sdk().await;
- let convenience_request = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_H_D_TAG, "Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000239")
- .expect("idempotency");
- let convenience_receipt = convenience_sdk
- .listings()
- .enqueue_publish_with_explicit_signer(
- convenience_request,
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect("convenience enqueue");
-
- assert_eq!(convenience_receipt, prepared_receipt);
-}
-
-#[tokio::test]
-async fn enqueue_prepared_publish_returns_structured_actor_errors() {
- let (_tempdir, sdk) = directory_sdk().await;
- let prepared = sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::new(
- actor(),
- listing(LISTING_I_D_TAG, "Coffee"),
- ))
- .expect("prepared");
- let error = sdk
- .listings()
- .enqueue_prepared_publish_with_explicit_signer(
- &non_seller_actor(),
- prepared,
- TargetPolicy::default_profile(),
- Some(
- SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000023e")
- .expect("idempotency key"),
- ),
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect_err("actor error");
-
- assert!(matches!(error, RadrootsSdkError::UnauthorizedActor { .. }));
-}
-
-#[tokio::test]
-async fn enqueue_prepared_publish_returns_sanitized_signer_errors() {
- let (_tempdir, sdk) = directory_sdk().await;
- let actor = actor();
- let prepared = sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::new(
- actor.clone(),
- listing(LISTING_J_D_TAG, "Coffee"),
- ))
- .expect("prepared");
- let error = sdk
- .listings()
- .enqueue_prepared_publish_with_explicit_signer(
- &actor,
- prepared,
- TargetPolicy::default_profile(),
- Some(
- SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000023f")
- .expect("idempotency key"),
- ),
- &FixtureSigner::new(other_pubkey()),
- )
- .await
- .expect_err("signer error");
- let message = error.to_string();
-
- assert!(matches!(error, RadrootsSdkError::UnauthorizedActor { .. }));
- assert!(!message.contains("raw"));
- assert!(!message.contains("ffff"));
-}
-
-#[tokio::test]
-async fn explicit_historical_created_at_does_not_backdate_observed_at_ms() {
- let (_tempdir, sdk) = directory_sdk().await;
- let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_600_000_000);
- let observed_at_ms = 1_700_000_000_000;
- let request = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_K_D_TAG, "Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023a")
- .expect("idempotency")
- .with_created_at(created_at);
-
- let receipt = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey()))
- .await
- .expect("enqueue");
-
- let paths = sdk.storage_paths().expect("paths");
- let event_store = RadrootsEventStore::open_file(&paths.runtime_path)
- .await
- .expect("event store");
- let stored_event = event_store
- .valid_event(&receipt.signed_event_id.to_hex())
- .await
- .expect("event lookup")
- .expect("stored event");
- let stored_event = stored_event.raw_event();
- assert_eq!(stored_event.created_at, 1_600_000_000);
- assert_eq!(stored_event.inserted_at_ms, observed_at_ms);
- assert_eq!(stored_event.updated_at_ms, observed_at_ms);
-
- let outbox = RadrootsOutbox::open_file(&paths.runtime_path)
- .await
- .expect("outbox");
- let outbox_event = outbox
- .get_event(receipt.outbox_event_id)
- .await
- .expect("outbox event")
- .expect("outbox event");
- assert_eq!(outbox_event.draft.created_at_u64(), 1_600_000_000);
- assert_eq!(
- outbox_event.event_store_ingested_at_ms,
- Some(observed_at_ms)
- );
- assert_eq!(outbox_event.created_at_ms, observed_at_ms);
- assert_eq!(outbox_event.updated_at_ms, observed_at_ms);
-}
-
-#[tokio::test]
-async fn enqueue_publish_returns_sanitized_signer_errors() {
- let (_tempdir, sdk) = directory_sdk().await;
- let request = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_C_D_TAG, "Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023d")
- .expect("idempotency key");
- let error = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(other_pubkey()))
- .await
- .expect_err("signer error");
- let message = error.to_string();
-
- assert!(matches!(error, RadrootsSdkError::UnauthorizedActor { .. }));
- assert!(!message.contains("raw"));
- assert!(!message.contains("ffff"));
-}
-
-#[tokio::test]
-async fn enqueue_publish_reports_preflight_idempotency_conflict_without_mutation() {
- let (_tempdir, sdk) = directory_sdk().await;
- let first = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_D_D_TAG, "Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000228")
- .expect("idempotency key");
- sdk.listings()
- .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(seller_pubkey()))
- .await
- .expect("first enqueue");
- let paths = sdk.storage_paths().expect("paths");
- let event_store = RadrootsEventStore::open_file(&paths.runtime_path)
- .await
- .expect("event store");
- let outbox = RadrootsOutbox::open_file(&paths.runtime_path)
- .await
- .expect("outbox");
- assert_eq!(
- event_store
- .status_summary()
- .await
- .expect("event store status")
- .total_events,
- 1
- );
- assert_eq!(
- outbox
- .status_summary(0)
- .await
- .expect("outbox status")
- .total_events,
- 1
- );
-
- let second = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_E_D_TAG, "Changed"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000228")
- .expect("idempotency key");
- let error = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(seller_pubkey()))
- .await
- .expect_err("conflict");
-
- assert!(matches!(
- error,
- RadrootsSdkError::IdempotencyConflict { ref operation_kind, .. }
- if operation_kind == LISTING_PUBLISH_OPERATION_KIND
- ));
- assert_eq!(
- error.recovery_actions(),
- vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey]
- );
- assert!(
- !error
- .to_string()
- .contains("01890f0e-6c00-7000-8000-000000000228")
- );
- assert_eq!(
- event_store
- .status_summary()
- .await
- .expect("event store status after conflict")
- .total_events,
- 1
- );
- assert_eq!(
- outbox
- .status_summary(0)
- .await
- .expect("outbox status after conflict")
- .total_events,
- 1
- );
-}
-
-#[tokio::test]
-async fn enqueue_publish_rolls_back_event_and_journal_when_outbox_conflicts_after_ingest() {
- let (_tempdir, sdk) = directory_sdk().await;
- let paths = sdk.storage_paths().expect("paths");
- let idempotency_key = "01890f0e-6c00-7000-8000-000000000120";
- let seeded_event_store = RadrootsEventStore::open_file(&paths.runtime_path)
- .await
- .expect("seed event store");
- let conflicting_operation = sqlx::query(
- "INSERT INTO outbox_operations(operation_kind, expected_pubkey, semantic_scope, trade_id, mutation_id, canonical_payload_sha256, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) VALUES (?, ?, 'generic_event', NULL, NULL, NULL, ?, ?, 'queued', ?, ?)",
- )
- .bind(LISTING_PUBLISH_OPERATION_KIND)
- .bind(seller_pubkey())
- .bind(idempotency_key)
- .bind("conflicting-digest")
- .bind(1_700_000_000_000i64)
- .bind(1_700_000_000_000i64)
- .execute(seeded_event_store.pool())
- .await
- .expect("seed conflicting operation")
- .last_insert_rowid();
- sqlx::query(
- "INSERT INTO outbox_event(operation_id, event_id, expected_pubkey, draft_json, state, attempt_count, next_attempt_after_ms, event_store_ingested, event_store_inserted, created_at_ms, updated_at_ms) VALUES (?, ?, ?, '{}', 'signed', 0, ?, 0, 0, ?, ?)",
- )
- .bind(conflicting_operation)
- .bind("0".repeat(64))
- .bind(seller_pubkey())
- .bind(1_700_000_000_000i64)
- .bind(1_700_000_000_000i64)
- .bind(1_700_000_000_000i64)
- .execute(seeded_event_store.pool())
- .await
- .expect("seed conflicting event");
-
- let request = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_E_D_TAG, "Rollback Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key(idempotency_key)
- .expect("idempotency key");
- let prepared = sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::new(
- actor(),
- listing(LISTING_E_D_TAG, "Rollback Coffee"),
- ))
- .expect("prepared");
- let error = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey()))
- .await
- .expect_err("outbox conflict");
- assert!(matches!(
- error,
- RadrootsSdkError::IdempotencyConflict { .. }
- ));
-
- let event_store = RadrootsEventStore::open_file(&paths.runtime_path)
- .await
- .expect("event store");
- assert!(
- event_store
- .raw_event(&prepared.expected_event_id().to_hex())
- .await
- .expect("event lookup")
- .is_none()
- );
- let journal_row = sqlx::query(
- "SELECT state, last_error_code FROM sdk_runtime_operation_journal WHERE operation_kind = ? AND actor_pubkey = ? AND idempotency_key = ?",
- )
- .bind(LISTING_PUBLISH_OPERATION_KIND)
- .bind(seller_pubkey())
- .bind(idempotency_key)
- .fetch_one(event_store.pool())
- .await
- .expect("journal row");
- let journal_state: String = journal_row.try_get("state").expect("journal state");
- let last_error_code: String = journal_row
- .try_get("last_error_code")
- .expect("journal error code");
- assert_eq!(journal_state, "failed_recoverable");
- assert_eq!(last_error_code, "idempotency_conflict");
- let recovery_count: i64 = sqlx::query(
- "SELECT COUNT(*) FROM sdk_runtime_recovery_receipt WHERE recovery_code = 'idempotency_conflict' AND operation_kind = ? AND actor_pubkey = ? AND idempotency_key = ?",
- )
- .bind(LISTING_PUBLISH_OPERATION_KIND)
- .bind(seller_pubkey())
- .bind(idempotency_key)
- .fetch_one(event_store.pool())
- .await
- .expect("recovery count")
- .try_get(0)
- .expect("recovery count value");
- assert_eq!(recovery_count, 1);
- let outbox = RadrootsOutbox::open_file(&paths.runtime_path)
- .await
- .expect("outbox");
- assert_eq!(
- outbox
- .status_summary(0)
- .await
- .expect("outbox status after rollback")
- .total_events,
- 1
- );
-}
-
-#[tokio::test]
-async fn enqueue_publish_uses_explicit_idempotency_key_across_equivalent_target_order() {
- let (_tempdir, sdk) = directory_sdk().await;
- let first = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_F_D_TAG, "Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_nostr_targets([RELAY_B, RELAY], NostrRelayUrlPolicy::Public)
- .expect("first transport targets")
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023b")
- .expect("first idempotency key");
- let second = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_F_D_TAG, "Coffee"),
- TargetPolicy::explicit(
- TargetSet::nostr_relays([RELAY, RELAY_B], NostrRelayUrlPolicy::Public)
- .expect("second transport targets"),
- ),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023b")
- .expect("second idempotency key");
-
- let first_receipt = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(seller_pubkey()))
- .await
- .expect("first enqueue");
- let second_receipt = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(seller_pubkey()))
- .await
- .expect("second enqueue");
-
- assert_eq!(
- first_receipt.outbox_event_id,
- second_receipt.outbox_event_id
- );
- assert_eq!(
- first_receipt.idempotency_digest_prefix,
- second_receipt.idempotency_digest_prefix
- );
- assert_eq!(second_receipt.state, SdkMutationState::StoredAndQueued);
-
- let paths = sdk.storage_paths().expect("paths");
- let outbox = RadrootsOutbox::open_file(&paths.runtime_path)
- .await
- .expect("outbox");
- let relay_urls = outbox
- .delivery_targets(first_receipt.outbox_event_id)
- .await
- .expect("delivery targets")
- .into_iter()
- .map(|target| target.endpoint_uri.to_string())
- .collect::<Vec<_>>();
- assert_eq!(relay_urls, vec![RELAY_B.to_owned(), RELAY.to_owned()]);
-}
-
-#[tokio::test]
-async fn listing_multi_target_profile_publishes_after_nostr_success_and_retains_reticulum() {
- let (_tempdir, sdk) = multi_target_directory_sdk().await;
- let enqueue_receipt = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(
- ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_G_D_TAG, "MultiTarget Coffee"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023c")
- .expect("idempotency"),
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect("enqueue");
- let adapter = RadrootsMockRelayPublishAdapter::new();
-
- let push_receipt = sdk
- .sync()
- .push_outbox_with_transport(
- &RadrootsNostrTransport::new(&adapter),
- PushOutboxRequest::new().with_limit(1),
- )
- .await
- .expect("push");
-
- assert_eq!(push_receipt.attempted_events, 1);
- assert_eq!(push_receipt.published_events, 1);
- assert_eq!(push_receipt.retryable_events, 0);
- assert_eq!(push_receipt.terminal_events, 0);
- let event = &push_receipt.events[0];
- assert_eq!(event.outbox_event_id, enqueue_receipt.outbox_event_id);
- assert_eq!(event.final_state, PushOutboxEventState::Published);
- assert_eq!(event.quorum, 1);
- assert!(event.quorum_met);
- assert_eq!(event.targets.len(), 1);
- assert_eq!(event.targets[0].endpoint_uri, RELAY);
- assert_eq!(
- event.targets[0].outcome_kind,
- PushOutboxTargetOutcomeKind::Accepted
- );
- assert_eq!(adapter.captured_raw_events().len(), 1);
-
- let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path)
- .await
- .expect("outbox");
- let stored = outbox
- .get_event(enqueue_receipt.outbox_event_id)
- .await
- .expect("stored")
- .expect("stored");
- assert_eq!(stored.state, RadrootsOutboxEventState::Published);
- let plans = outbox
- .delivery_plans(enqueue_receipt.outbox_event_id)
- .await
- .expect("delivery plans");
- assert_eq!(plans.len(), 1);
- assert_eq!(plans[0].required_success_count, 1);
- assert_eq!(plans[0].status, RadrootsOutboxDeliveryPlanStatus::Complete);
- let targets = outbox
- .delivery_targets(enqueue_receipt.outbox_event_id)
- .await
- .expect("delivery targets");
- assert_eq!(targets.len(), 2);
- assert!(targets.iter().any(|target| {
- target.endpoint_uri.to_string() == RELAY
- && target.status == RadrootsOutboxDeliveryTargetStatus::Accepted
- }));
- assert!(targets.iter().any(|target| {
- target.endpoint_uri.to_string() == "reticulum:local"
- && target.status == RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented
- && target.attempt_count == 0
- }));
-}
diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs
@@ -4,6 +4,7 @@ const MANIFEST: &str = include_str!("../Cargo.toml");
const ROOT: &str = include_str!("../src/lib.rs");
const CLIENT: &str = include_str!("../src/client.rs");
const FARM: &str = include_str!("../src/farm.rs");
+const LISTING: &str = include_str!("../src/listing.rs");
const SYNC: &str = include_str!("../src/sync.rs");
const TRANSPORT: &str = include_str!("../src/transport.rs");
@@ -230,6 +231,42 @@ fn farm_operations_preserve_pure_planning_commit_and_privacy_boundaries() {
assert!(!source_root.join("farms_runtime.rs").exists());
}
+#[test]
+fn listing_operations_reuse_trade_event_sync_and_privacy_boundaries() {
+ for required in [
+ "canonicalize_operational_listing_edit",
+ "RadrootsOperationalListingMutation",
+ "RadrootsOperationalListingLifecycleState",
+ "build_operational_listing_mutation_draft",
+ "radroots_sync::PushRequest::new",
+ "PrivateArtifactStore",
+ ] {
+ assert!(
+ LISTING.contains(required),
+ "missing listing boundary `{required}`"
+ );
+ }
+ for forbidden in [
+ "SdkMutationState",
+ "ListingEnqueueReceipt",
+ "enqueue_signed_workflow",
+ "local_event_seq",
+ "outbox_event_id",
+ "latitude:",
+ "longitude:",
+ ] {
+ assert!(
+ !LISTING.contains(forbidden),
+ "listing source contains retired duplicate or private representation `{forbidden}`"
+ );
+ }
+ assert!(
+ !std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("src/listings_runtime.rs")
+ .exists()
+ );
+}
+
fn dependency_names(manifest: &str) -> BTreeSet<&str> {
let dependencies = manifest
.split_once("[dependencies]")
diff --git a/crates/sdk/tests/unit/listings_runtime_tests.rs b/crates/sdk/tests/unit/listings_runtime_tests.rs
@@ -1,600 +0,0 @@
-use super::*;
-use crate::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider};
-use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit};
-use radroots_event::{
- contract::AuthorRole,
- farm::FarmRef,
- farm::resource_area::ResourceAreaRef,
- id::{DTag, InventoryBinId},
- listing::operational::{
- OperationalListingAvailability, OperationalListingBin, OperationalListingDeliveryMethod,
- OperationalListingProduct, OperationalListingPublicLocation, OperationalListingStatus,
- },
-};
-
-use crate::fixture_signer::{FixtureSigner, fixture_alice_pubkey, fixture_bob_pubkey};
-use crate::serializer_failure::assert_struct_serialize_error_paths;
-use radroots_signing::actor::ActorSource;
-
-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 RELAY_A: &str = "wss://relay-a.radroots.test";
-const RELAY_B: &str = "wss://relay-b.radroots.test";
-
-fn seller_pubkey() -> &'static str {
- fixture_alice_pubkey()
-}
-
-fn actor() -> Actor {
- Actor::from_public_key_hex(
- seller_pubkey(),
- ActorSource::ExplicitPublicKey,
- [AuthorRole::Seller],
- )
- .expect("actor")
-}
-
-fn listing(d_tag: &str, title: &str) -> OperationalListing {
- listing_for_seller(seller_pubkey(), d_tag, title)
-}
-
-fn listing_for_seller(seller: &str, d_tag: &str, title: &str) -> OperationalListing {
- OperationalListing {
- d_tag: DTag::parse(d_tag).expect("d tag"),
- published_at: None,
- farm: FarmRef {
- pubkey: seller.to_owned(),
- d_tag: FARM_D_TAG.to_owned(),
- },
- product: OperationalListingProduct {
- key: "lettuce".to_owned(),
- title: title.to_owned(),
- category: "greens".to_owned(),
- summary: Some("Fresh greens".to_owned()),
- process: None,
- lot: None,
- location: None,
- profile: None,
- year: None,
- },
- primary_bin_id: InventoryBinId::parse("bin-1").expect("bin id"),
- bins: vec![OperationalListingBin {
- bin_id: InventoryBinId::parse("bin-1").expect("bin id"),
- quantity: Quantity::try_new(Decimal::from(12u32), Unit::Each)
- .expect("positive fixture quantity"),
- price_per_canonical_unit: QuantityPrice::try_new(
- Money::try_new(Decimal::from(4u32), Currency::USD)
- .expect("non-negative fixture money"),
- Quantity::try_new(Decimal::from(1u32), Unit::Each)
- .expect("positive fixture pricing quantity"),
- )
- .expect("non-zero fixture pricing quantity"),
- 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: Some(Decimal::from(12u32)),
- availability: Some(OperationalListingAvailability::Status {
- status: OperationalListingStatus::Active,
- }),
- delivery_method: Some(OperationalListingDeliveryMethod::Pickup),
- location: Some(OperationalListingPublicLocation {
- primary: "Victoria".to_owned(),
- city: Some("Victoria".to_owned()),
- region: Some("British Columbia".to_owned()),
- country: Some("CA".to_owned()),
- geohash: "c287g".to_owned(),
- }),
- images: None,
- }
-}
-
-#[test]
-fn listing_runtime_request_builders_and_serializers_cover_success_paths() {
- let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_321);
- let prepare =
- ListingPreparePublishRequest::new(actor(), listing(LISTING_A_D_TAG, "Serialized Greens"))
- .with_created_at(created_at);
- assert_struct_serialize_error_paths(&prepare, 3);
- let prepare_json = serde_json::to_value(&prepare).expect("prepare json");
- assert_eq!(prepare_json["actor"]["pubkey"], seller_pubkey());
- assert_eq!(prepare_json["created_at"], 1_700_000_321);
-
- let enqueue = ListingEnqueuePublishRequest::from_document(
- actor(),
- RadrootsOperationalListingEditDocumentV1::new(listing(LISTING_B_D_TAG, "Queued Greens")),
- TargetPolicy::default_profile(),
- )
- .try_with_nostr_targets([RELAY_A, RELAY_B], NostrRelayUrlPolicy::Public)
- .expect("relay targets")
- .with_idempotency_key(
- SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000022f").expect("key"),
- )
- .with_created_at(created_at);
- assert_struct_serialize_error_paths(&enqueue, 5);
- let enqueue_json = serde_json::to_value(&enqueue).expect("enqueue json");
- assert_eq!(enqueue_json["target_policy"]["kind"], "explicit");
- assert_eq!(enqueue_json["created_at"], 1_700_000_321);
- assert!(
- !enqueue_json
- .to_string()
- .contains("01890f0e-6c00-7000-8000-00000000022f")
- );
-
- let try_key = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_C_D_TAG, "Try Key Greens"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000230")
- .expect("try key");
- assert_eq!(
- serde_json::to_value(&try_key).expect("try key json")["idempotency_key"]["len"],
- "01890f0e-6c00-7000-8000-000000000230".len()
- );
-}
-
-#[test]
-fn listing_request_builders_reject_invalid_options_and_timestamp_bounds() {
- let invalid_key = ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_A_D_TAG, "Invalid Key Greens"),
- TargetPolicy::default_profile(),
- )
- .try_with_idempotency_key("");
- assert!(invalid_key.is_err());
-
- let timestamp_error = listing_publish_plan(
- &actor(),
- RadrootsOperationalListingEditDocumentV1::new(listing(LISTING_B_D_TAG, "Future Greens")),
- RadrootsSdkTimestamp::from_unix_seconds(u64::MAX),
- )
- .expect_err("timestamp error");
- assert!(matches!(
- timestamp_error,
- RadrootsSdkError::TimestampOutOfRange { .. }
- ));
-
- let mut invalid_resource_area_listing =
- listing(LISTING_C_D_TAG, "Invalid Resource Area Greens");
- invalid_resource_area_listing.resource_area = Some(ResourceAreaRef {
- pubkey: seller_pubkey().to_owned(),
- d_tag: "bad d tag".to_owned(),
- });
- let mutation_error = listing_publish_plan(
- &actor(),
- RadrootsOperationalListingEditDocumentV1::new(invalid_resource_area_listing),
- RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000),
- )
- .expect_err("mutation error");
- assert!(matches!(
- mutation_error,
- RadrootsSdkError::ListingMutation { message } if message.contains("failed to encode")
- ));
-}
-
-#[tokio::test]
-async fn listing_client_prepare_resolves_default_and_explicit_created_at() {
- let sdk = crate::RadrootsClient::builder()
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_400))
- .build()
- .await
- .expect("sdk");
- let default_plan = sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::new(
- actor(),
- listing(LISTING_A_D_TAG, "Default Clock Greens"),
- ))
- .expect("default plan");
- assert_eq!(
- default_plan.created_at(),
- RadrootsSdkTimestamp::from_unix_seconds(1_700_000_400)
- );
-
- let explicit_plan = sdk
- .listings()
- .prepare_publish(
- ListingPreparePublishRequest::new(
- actor(),
- listing(LISTING_B_D_TAG, "Explicit Clock Greens"),
- )
- .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_401)),
- )
- .expect("explicit plan");
- assert_eq!(
- explicit_plan.created_at(),
- RadrootsSdkTimestamp::from_unix_seconds(1_700_000_401)
- );
-}
-
-#[tokio::test]
-async fn listing_prepared_plan_rejects_forged_state_before_signing_or_mutation() {
- let sdk = crate::RadrootsClient::builder()
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_450))
- .build()
- .await
- .expect("sdk");
- let actor = actor();
- let plan = sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::new(
- actor.clone(),
- listing(LISTING_A_D_TAG, "Sealed Plan Greens"),
- ))
- .expect("plan");
- assert_eq!(
- serde_json::to_value(&plan).expect("plan json"),
- serde_json::json!({
- "public_listing_addr": plan.public_listing_addr(),
- "expected_event_id": plan.expected_event_id(),
- "frozen_draft": plan.frozen_draft(),
- "created_at": plan.created_at(),
- })
- );
- // Frozen drafts enforce registry contract-kind consistency, so a valid foreign pair is the
- // safe representable substitution for both identity fields.
- let foreign_draft = EventDraft::new(
- "radroots.social.geochat.v1",
- radroots_event::envelope::kind::KIND_GEOCHAT,
- plan.created_at().unix_seconds(),
- Vec::new(),
- "Foreign draft",
- seller_pubkey(),
- )
- .expect("valid foreign draft");
- let mut forged_contract_kind = plan.clone();
- forged_contract_kind.frozen_draft = foreign_draft;
- assert!(matches!(
- validate_listing_publish_plan(&forged_contract_kind),
- Err(RadrootsSdkError::InvalidRequest { ref message })
- if message.contains("contract or kind")
- ));
-
- let mut forged_event_id = plan.clone();
- forged_event_id.expected_event_id =
- EventId::parse(fixture_bob_pubkey()).expect("alternate event ID");
- assert!(matches!(
- validate_listing_publish_plan(&forged_event_id),
- Err(RadrootsSdkError::InvalidRequest { ref message })
- if message.contains("expected event ID")
- ));
-
- let mut forged_address = plan.clone();
- forged_address.public_listing_addr = ClassifiedListingAddress::parse(format!(
- "{KIND_CLASSIFIED_LISTING}:{}:{LISTING_B_D_TAG}",
- seller_pubkey()
- ))
- .expect("alternate listing address");
- assert!(matches!(
- validate_listing_publish_plan(&forged_address),
- Err(RadrootsSdkError::InvalidRequest { ref message })
- if message.contains("listing address")
- ));
-
- let mut forged_created_at = plan.clone();
- forged_created_at.created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_451);
- assert!(matches!(
- validate_listing_publish_plan(&forged_created_at),
- Err(RadrootsSdkError::InvalidRequest { ref message })
- if message.contains("created-at timestamp")
- ));
-
- let alternate_plan = sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::new(
- actor.clone(),
- listing(LISTING_B_D_TAG, "Alternate Plan Greens"),
- ))
- .expect("alternate plan");
- let mut forged_draft = plan;
- forged_draft.frozen_draft = alternate_plan.frozen_draft;
- assert!(matches!(
- validate_listing_publish_plan(&forged_draft),
- Err(RadrootsSdkError::InvalidRequest { ref message })
- if message.contains("expected event ID")
- ));
-
- let error = sdk
- .listings()
- .enqueue_prepared_publish_with_explicit_signer(
- &actor,
- forged_contract_kind,
- TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public)
- .expect("transport targets"),
- Some(
- SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000245")
- .expect("idempotency"),
- ),
- &FixtureSigner::new(fixture_bob_pubkey()),
- )
- .await
- .expect_err("forged plan");
- assert!(matches!(
- error,
- RadrootsSdkError::InvalidRequest { ref message }
- if message.contains("contract or kind")
- ));
- let error = sdk
- .listings()
- .enqueue_prepared_publish(
- &actor,
- forged_address,
- TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public)
- .expect("transport targets"),
- Some(
- SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000246")
- .expect("idempotency"),
- ),
- )
- .await
- .expect_err("forged configured-signer plan");
- assert!(matches!(
- error,
- RadrootsSdkError::InvalidRequest { ref message }
- if message.contains("listing address")
- ));
- assert_eq!(
- sdk._event_store
- .status_summary()
- .await
- .expect("event store status")
- .total_events,
- 0
- );
- assert_eq!(
- sdk._outbox
- .status_summary(0)
- .await
- .expect("outbox status")
- .total_events,
- 0
- );
- let journal_count: i64 =
- sqlx::query_scalar("SELECT COUNT(*) FROM sdk_runtime_operation_journal")
- .fetch_one(sdk._event_store.pool())
- .await
- .expect("journal count");
- assert_eq!(journal_count, 0);
-}
-
-#[tokio::test]
-async fn listing_client_prepare_reports_clock_errors() {
- let sdk = crate::RadrootsClient::builder()
- .clock(crate::RadrootsSdkClock::BeforeUnixEpoch)
- .build()
- .await
- .expect("sdk");
- let error = sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::new(
- actor(),
- listing(LISTING_A_D_TAG, "Clock Error Greens"),
- ))
- .expect_err("clock error");
- assert!(matches!(error, RadrootsSdkError::ClockBeforeUnixEpoch));
-}
-
-#[tokio::test]
-async fn listing_enqueue_publish_reports_prepare_errors_before_signing() {
- let sdk = crate::RadrootsClient::builder()
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500))
- .build()
- .await
- .expect("sdk");
- let error = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(
- ListingEnqueuePublishRequest::new(
- actor(),
- listing(LISTING_A_D_TAG, "Future Enqueue Greens"),
- TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public)
- .expect("transport targets"),
- )
- .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)),
- &FixtureSigner::new(seller_pubkey()),
- )
- .await
- .expect_err("prepare error");
- assert!(matches!(
- error,
- RadrootsSdkError::TimestampOutOfRange { .. }
- ));
-
- let mut invalid_listing = listing(LISTING_B_D_TAG, "Invalid Inventory Greens");
- invalid_listing.inventory_available = None;
- let error = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(
- ListingEnqueuePublishRequest::new(
- actor(),
- invalid_listing,
- TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public)
- .expect("transport targets"),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000239")
- .expect("idempotency"),
- &FixtureSigner::new(fixture_bob_pubkey()),
- )
- .await
- .expect_err("invalid listing model");
- assert!(matches!(
- error,
- RadrootsSdkError::ListingValidation {
- kind: crate::RadrootsSdkListingValidationErrorKind::MissingInventory,
- ref message,
- } if message == "missing listing inventory"
- ));
- assert_eq!(
- sdk._event_store
- .status_summary()
- .await
- .expect("event store status")
- .total_events,
- 0
- );
- assert_eq!(
- sdk._outbox
- .status_summary(0)
- .await
- .expect("outbox status")
- .total_events,
- 0
- );
- let journal_count: i64 =
- sqlx::query_scalar("SELECT COUNT(*) FROM sdk_runtime_operation_journal")
- .fetch_one(sdk._event_store.pool())
- .await
- .expect("journal count");
- assert_eq!(journal_count, 0);
-}
-
-#[tokio::test]
-async fn listing_client_enqueue_methods_cover_source_attached_workflow_paths() {
- let sdk = crate::RadrootsClient::builder()
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500))
- .build()
- .await
- .expect("sdk");
- let signer = FixtureSigner::new(seller_pubkey());
- let actor = actor();
- let receipt = sdk
- .listings()
- .enqueue_publish_with_explicit_signer(
- ListingEnqueuePublishRequest::new(
- actor.clone(),
- listing(LISTING_A_D_TAG, "Enqueued Greens"),
- TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public)
- .expect("transport targets"),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000231")
- .expect("idempotency"),
- &signer,
- )
- .await
- .expect("enqueue listing");
- assert_eq!(receipt.signed_event_id, receipt.expected_event_id);
- assert_eq!(receipt.state, SdkMutationState::StoredAndQueued);
-
- let plan = sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::from_document(
- actor.clone(),
- RadrootsOperationalListingEditDocumentV1::new(listing(
- LISTING_B_D_TAG,
- "Prepared Greens",
- )),
- ))
- .expect("prepared listing");
- let prepared = sdk
- .listings()
- .enqueue_prepared_publish_with_explicit_signer(
- &actor,
- plan,
- TargetPolicy::try_nostr_relays([RELAY_B], NostrRelayUrlPolicy::Public)
- .expect("prepared transport targets"),
- Some(
- SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000232")
- .expect("prepared idempotency"),
- ),
- &signer,
- )
- .await
- .expect("enqueue prepared listing");
- assert_eq!(prepared.signed_event_id, prepared.expected_event_id);
- assert_eq!(prepared.local_event_seq, 2);
-}
-
-#[tokio::test]
-async fn listing_configured_local_signer_enqueues_publish_without_explicit_signer() {
- let sdk = crate::RadrootsClient::builder()
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500))
- .signer_provider(RadrootsSdkSignerProvider::LocalKey(
- RadrootsSdkLocalKeySigner::from_signer(
- FixtureSigner::new(seller_pubkey()),
- seller_pubkey(),
- )
- .expect("signer"),
- ))
- .build()
- .await
- .expect("sdk");
- let actor = Actor::from_public_key_hex(
- seller_pubkey(),
- ActorSource::ExplicitPublicKey,
- [AuthorRole::Seller],
- )
- .expect("actor");
-
- let receipt = sdk
- .listings()
- .enqueue_publish(
- ListingEnqueuePublishRequest::new(
- actor,
- listing_for_seller(seller_pubkey(), LISTING_C_D_TAG, "Configured Greens"),
- TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public)
- .expect("transport targets"),
- )
- .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000232")
- .expect("idempotency"),
- )
- .await
- .expect("enqueue listing");
-
- assert_eq!(receipt.signed_event_id, receipt.expected_event_id);
- assert_eq!(receipt.state, SdkMutationState::StoredAndQueued);
-}
-
-#[tokio::test]
-async fn listing_configured_enqueue_reports_missing_signer_after_prepare() {
- let sdk = crate::RadrootsClient::builder()
- .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500))
- .build()
- .await
- .expect("sdk");
- let actor = actor();
- assert!(matches!(
- sdk.listings()
- .enqueue_publish(
- ListingEnqueuePublishRequest::new(
- actor.clone(),
- listing(LISTING_A_D_TAG, "Configured Prepare Error Greens"),
- TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public)
- .expect("transport targets"),
- )
- .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)),
- )
- .await,
- Err(RadrootsSdkError::TimestampOutOfRange { .. })
- ));
- let plan = sdk
- .listings()
- .prepare_publish(ListingPreparePublishRequest::new(
- actor.clone(),
- listing(LISTING_A_D_TAG, "Missing Signer Greens"),
- ))
- .expect("plan");
-
- assert!(matches!(
- sdk.listings()
- .enqueue_prepared_publish(
- &actor,
- plan,
- TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public)
- .expect("transport targets"),
- Some(
- SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000233")
- .expect("idempotency"),
- ),
- )
- .await,
- Err(RadrootsSdkError::SignerUnavailable { .. })
- ));
-}