sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

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:
Mcrates/sdk/src/error.rs | 24++++++++++++++++++++++++
Mcrates/sdk/src/lib.rs | 6++++++
Acrates/sdk/src/listings_runtime.rs | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/product_clients.rs | 6+++---
Acrates/sdk/tests/listings_runtime.rs | 283+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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")); +}