sdk

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

commit 78a7efa47971856939798226fe443c2b3769c972
parent a91221f03664e4d45556268b4d5014c0be421e92
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 16:53:38 -0700

sdk: enrich listing enqueue receipts

- return typed listing addresses, event IDs, store seq, and outbox IDs
- surface inserted versus existing enqueue state in product receipts
- add redacted partial outbox enqueue recovery context
- remove the stale nested local mutation receipt wrapper

Diffstat:
Mcrates/sdk/examples/runtime_local.rs | 2+-
Mcrates/sdk/src/error.rs | 45++++++++++++++++++++++++++++++++++++---------
Mcrates/sdk/src/lib.rs | 10+++-------
Mcrates/sdk/src/listings_runtime.rs | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Dcrates/sdk/src/receipt.rs | 18------------------
Mcrates/sdk/src/runtime.rs | 13+------------
Mcrates/sdk/tests/listings_runtime.rs | 37+++++++++++++++++++++----------------
Mcrates/sdk/tests/runtime_foundation.rs | 17++++++++++-------
Mcrates/sdk/tests/sync_runtime.rs | 2--
9 files changed, 160 insertions(+), 98 deletions(-)

diff --git a/crates/sdk/examples/runtime_local.rs b/crates/sdk/examples/runtime_local.rs @@ -108,7 +108,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { assert_eq!( prepared.public_listing_addr.as_str(), - enqueue.listing_address.as_str() + enqueue.public_listing_addr.as_str() ); assert_eq!(push.attempted_events, 1); assert!(!order_status.found); diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -11,10 +11,20 @@ pub enum RadrootsSdkRecoveryAction { #[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsSdkPartialLocalMutationFailure { + OutboxEnqueue, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsSdkPartialLocalMutationError { + pub event_id: Option<String>, + pub operation_kind: String, + pub idempotency_digest_prefix: Option<String>, pub stored: bool, pub queued: bool, pub recovery: RadrootsSdkRecoveryAction, + pub failure: RadrootsSdkPartialLocalMutationFailure, } #[cfg(feature = "runtime")] @@ -36,15 +46,23 @@ pub enum RadrootsSdkError { #[cfg(feature = "runtime")] impl RadrootsSdkError { - pub fn partial_local_mutation( - stored: bool, - queued: bool, - recovery: RadrootsSdkRecoveryAction, + pub fn partial_local_mutation(error: RadrootsSdkPartialLocalMutationError) -> Self { + Self::PartialLocalMutation(error) + } + + pub fn partial_outbox_enqueue_mutation( + event_id: impl Into<String>, + operation_kind: impl Into<String>, + idempotency_digest_prefix: impl Into<String>, ) -> Self { Self::PartialLocalMutation(RadrootsSdkPartialLocalMutationError { - stored, - queued, - recovery, + event_id: Some(event_id.into()), + operation_kind: operation_kind.into(), + idempotency_digest_prefix: Some(idempotency_digest_prefix.into()), + stored: true, + queued: false, + recovery: RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey, + failure: RadrootsSdkPartialLocalMutationFailure::OutboxEnqueue, }) } } @@ -77,8 +95,17 @@ impl fmt::Display for RadrootsSdkError { Self::Projection { message } => write!(f, "sdk projection error: {message}"), Self::PartialLocalMutation(error) => write!( f, - "sdk local mutation partially completed: stored={}, queued={}, recovery={:?}", - error.stored, error.queued, error.recovery + "sdk local mutation partially completed: event_id={}, operation_kind={}, idempotency_digest_prefix={}, stored={}, queued={}, failure={:?}, recovery={:?}", + error.event_id.as_deref().unwrap_or("<unknown>"), + error.operation_kind, + error + .idempotency_digest_prefix + .as_deref() + .unwrap_or("<none>"), + error.stored, + error.queued, + error.failure, + error.recovery ), } } diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -41,8 +41,6 @@ mod product_clients; mod profile; pub mod protocol; #[cfg(feature = "runtime")] -mod receipt; -#[cfg(feature = "runtime")] mod runtime; #[cfg(feature = "runtime")] mod runtime_targets; @@ -51,12 +49,13 @@ mod sync_runtime; #[cfg(feature = "runtime")] pub use crate::error::{ - RadrootsSdkError, RadrootsSdkPartialLocalMutationError, RadrootsSdkRecoveryAction, + RadrootsSdkError, RadrootsSdkPartialLocalMutationError, RadrootsSdkPartialLocalMutationFailure, + RadrootsSdkRecoveryAction, }; #[cfg(feature = "runtime")] pub use crate::listings_runtime::{ ListingEnqueuePublishRequest, ListingEnqueueReceipt, ListingPreparePublishRequest, - ListingPublishPlan, + ListingPublishPlan, SdkMutationState, }; #[cfg(feature = "runtime")] pub use crate::orders_runtime::{ @@ -66,9 +65,6 @@ pub use crate::orders_runtime::{ }; #[cfg(feature = "runtime")] pub use crate::product_clients::{ListingsClient, OrdersClient, SyncClient}; -#[cfg(feature = "runtime")] -pub use crate::receipt::{RadrootsSdkEventReference, RadrootsSdkLocalMutationReceipt}; -#[cfg(feature = "runtime")] pub use crate::runtime::{ RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig, RadrootsSdkStoragePaths, RadrootsSdkTimestamp, diff --git a/crates/sdk/src/listings_runtime.rs b/crates/sdk/src/listings_runtime.rs @@ -1,8 +1,7 @@ #[cfg(feature = "runtime")] use crate::{ - ListingsClient, RadrootsSdkError, RadrootsSdkEventReference, RadrootsSdkLocalMutationReceipt, - RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, SdkIdempotencyKey, SdkRelayTargetPolicy, - SdkRelayTargetSet, SdkRelayUrlPolicy, + ListingsClient, RadrootsSdkError, RadrootsSdkTimestamp, SdkIdempotencyKey, + SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, }; #[cfg(feature = "runtime")] use radroots_authority::{RadrootsActorContext, RadrootsEventSigner, sign_authorized_draft}; @@ -16,12 +15,14 @@ use radroots_events::{ listing::RadrootsListing, }; #[cfg(feature = "runtime")] -use radroots_outbox::RadrootsOutboxSignedOperationInput; +use radroots_outbox::{RadrootsOutboxEnqueueStatus, RadrootsOutboxSignedOperationInput}; #[cfg(feature = "runtime")] use radroots_trade::listing::{ RadrootsCanonicalListingDraft, RadrootsListingDraftDocumentV1, RadrootsListingMutation, build_listing_mutation_draft, canonicalize_listing_draft, }; +#[cfg(feature = "runtime")] +use sha2::{Digest, Sha256}; #[cfg(feature = "runtime")] const LISTING_PUBLISH_OPERATION_KIND: &str = "listing.publish.v1"; @@ -143,9 +144,33 @@ pub struct ListingPublishPlan { #[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq)] +pub enum SdkMutationState { + Inserted, + Existing, +} + +#[cfg(feature = "runtime")] +impl From<RadrootsOutboxEnqueueStatus> for SdkMutationState { + fn from(value: RadrootsOutboxEnqueueStatus) -> Self { + match value { + RadrootsOutboxEnqueueStatus::Inserted => Self::Inserted, + RadrootsOutboxEnqueueStatus::Existing => Self::Existing, + } + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct ListingEnqueueReceipt { - pub listing_address: String, - pub local: RadrootsSdkLocalMutationReceipt, + pub public_listing_addr: RadrootsListingAddress, + pub draft_listing_addr: RadrootsListingAddress, + pub expected_event_id: RadrootsEventId, + pub signed_event_id: RadrootsEventId, + 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")] @@ -181,14 +206,18 @@ impl<'sdk> ListingsClient<'sdk> { )?, }; let observed_at_ms = i64::from(plan.frozen_draft.created_at) * 1_000; + let signed_event_id = parse_event_id(signed_event.id.as_str(), "signed event id")?; 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 target_relay_values = target_relays.into_vec(); + let partial_failure_digest_prefix = + outbox_idempotency_digest_prefix(&plan, target_relay_values.as_slice())?; let outbox_input = signed_outbox_input( &plan, signed_event.clone(), - target_relays.into_vec(), + target_relay_values, idempotency_key, ingest_receipt.inserted, observed_at_ms, @@ -199,28 +228,23 @@ impl<'sdk> ListingsClient<'sdk> { .enqueue_signed_operation(outbox_input) .await .map_err(|_| { - RadrootsSdkError::partial_local_mutation( - true, - false, - RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey, + RadrootsSdkError::partial_outbox_enqueue_mutation( + signed_event_id.as_str(), + LISTING_PUBLISH_OPERATION_KIND, + partial_failure_digest_prefix.as_str(), ) })?; + let idempotency_digest_prefix = digest_prefix(outbox_receipt.idempotency_digest.as_str()); Ok(ListingEnqueueReceipt { - listing_address: plan.public_listing_addr.into_string(), - 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(), - ), - }, + public_listing_addr: plan.public_listing_addr, + draft_listing_addr: plan.draft_listing_addr, + expected_event_id: plan.expected_event_id, + signed_event_id, + local_event_seq: ingest_receipt.seq, + outbox_operation_id: outbox_receipt.operation_id, + outbox_event_id: outbox_receipt.outbox_event_id, + state: outbox_receipt.status.into(), + idempotency_digest_prefix: Some(idempotency_digest_prefix), }) } @@ -281,6 +305,44 @@ fn listing_publish_plan( } #[cfg(feature = "runtime")] +#[derive(serde::Serialize)] +struct ListingOutboxDigestInput<'a> { + operation_kind: &'static str, + expected_pubkey: &'a str, + draft: &'a RadrootsFrozenEventDraft, + target_relays: &'a [String], +} + +#[cfg(feature = "runtime")] +fn outbox_idempotency_digest_prefix( + plan: &ListingPublishPlan, + target_relays: &[String], +) -> Result<String, RadrootsSdkError> { + let input = ListingOutboxDigestInput { + operation_kind: LISTING_PUBLISH_OPERATION_KIND, + expected_pubkey: plan.frozen_draft.expected_pubkey.as_str(), + draft: &plan.frozen_draft, + target_relays, + }; + let bytes = serde_json::to_vec(&input).map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("listing outbox idempotency digest failed: {error}"), + })?; + Ok(digest_prefix(hex::encode(Sha256::digest(bytes)).as_str())) +} + +#[cfg(feature = "runtime")] +fn digest_prefix(digest: &str) -> String { + digest.chars().take(12).collect() +} + +#[cfg(feature = "runtime")] +fn parse_event_id(value: &str, field: &str) -> Result<RadrootsEventId, RadrootsSdkError> { + RadrootsEventId::parse(value).map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("{field} is invalid: {error}"), + }) +} + +#[cfg(feature = "runtime")] fn signed_outbox_input( plan: &ListingPublishPlan, signed_event: RadrootsSignedNostrEvent, diff --git a/crates/sdk/src/receipt.rs b/crates/sdk/src/receipt.rs @@ -1,18 +0,0 @@ -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsSdkEventReference { - pub event_id: String, - pub pubkey: String, - pub kind: u32, - pub created_at: u32, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsSdkLocalMutationReceipt { - pub event: RadrootsSdkEventReference, - pub stored: bool, - pub queued: bool, - pub outbox_event_id: Option<i64>, - pub idempotency_key_digest_prefix: Option<String>, -} diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -1,7 +1,7 @@ #[cfg(feature = "runtime")] use crate::{ ListingsClient, OrdersClient, RadrootsSdkError, SdkRelayTargetSet, SdkRelayUrlPolicy, - SyncClient, error::RadrootsSdkRecoveryAction, + SyncClient, }; #[cfg(feature = "runtime")] use radroots_event_store::RadrootsEventStore; @@ -228,14 +228,3 @@ async fn open_directory_storage(path: &Path) -> Result<OpenedRuntimeStorage, Rad paths: Some(paths), }) } - -#[cfg(feature = "runtime")] -impl RadrootsSdk { - pub fn partial_local_mutation_error(stored: bool, queued: bool) -> RadrootsSdkError { - RadrootsSdkError::partial_local_mutation( - stored, - queued, - RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey, - ) - } -} diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs @@ -19,8 +19,8 @@ use radroots_events::{ use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState}; use radroots_sdk::{ ListingEnqueuePublishRequest, ListingPreparePublishRequest, RadrootsSdk, RadrootsSdkError, - RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, SdkRelayTargetPolicy, SdkRelayTargetSet, - SdkRelayUrlPolicy, + RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, SdkMutationState, SdkRelayTargetPolicy, + SdkRelayTargetSet, SdkRelayUrlPolicy, }; const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -239,14 +239,15 @@ async fn enqueue_publish_stores_event_and_queues_signed_outbox_without_publish() .await .expect("enqueue"); - assert_eq!( - receipt.local.event.event_id, - prepared.expected_event_id.as_str() - ); - 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()); + 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.draft_listing_addr, prepared.draft_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::Inserted); + assert!(receipt.idempotency_digest_prefix.is_some()); let paths = sdk.storage_paths().expect("paths"); let event_store = RadrootsEventStore::open_file(&paths.event_store_path) @@ -254,7 +255,7 @@ async fn enqueue_publish_stores_event_and_queues_signed_outbox_without_publish() .expect("event store"); assert!( event_store - .get_event(receipt.local.event.event_id.as_str()) + .get_event(receipt.signed_event_id.as_str()) .await .expect("event lookup") .is_some() @@ -264,7 +265,7 @@ async fn enqueue_publish_stores_event_and_queues_signed_outbox_without_publish() .await .expect("outbox"); let outbox_event = outbox - .get_event(receipt.local.outbox_event_id.expect("outbox event")) + .get_event(receipt.outbox_event_id) .await .expect("outbox event") .expect("outbox event"); @@ -325,6 +326,9 @@ async fn enqueue_publish_reports_partial_local_mutation_after_outbox_conflict() RadrootsSdkError::PartialLocalMutation(ref partial) if partial.stored && !partial.queued + && partial.event_id.is_some() + && partial.operation_kind == "listing.publish.v1" + && partial.idempotency_digest_prefix.is_some() && partial.recovery == RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey )); assert!(!error.to_string().contains("idem-d")); @@ -361,11 +365,12 @@ async fn enqueue_publish_derives_order_independent_idempotency_key() { .expect("second enqueue"); assert_eq!( - first_receipt.local.outbox_event_id, - second_receipt.local.outbox_event_id + first_receipt.outbox_event_id, + second_receipt.outbox_event_id ); assert_eq!( - first_receipt.local.idempotency_key_digest_prefix, - second_receipt.local.idempotency_key_digest_prefix + first_receipt.idempotency_digest_prefix, + second_receipt.idempotency_digest_prefix ); + assert_eq!(second_receipt.state, SdkMutationState::Existing); } diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs @@ -1,9 +1,9 @@ #![cfg(feature = "runtime")] use radroots_sdk::{ - RadrootsSdk, RadrootsSdkClock, RadrootsSdkError, RadrootsSdkRecoveryAction, - RadrootsSdkStorageConfig, RadrootsSdkTimestamp, SDK_IDEMPOTENCY_KEY_MAX_LEN, - SDK_RELAY_TARGET_MAX_COUNT, SdkIdempotencyKey, SdkRelayTargetSet, SdkRelayUrlPolicy, + RadrootsSdk, RadrootsSdkClock, RadrootsSdkError, RadrootsSdkStorageConfig, + RadrootsSdkTimestamp, SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_RELAY_TARGET_MAX_COUNT, + SdkIdempotencyKey, SdkRelayTargetSet, SdkRelayUrlPolicy, }; #[tokio::test] @@ -127,13 +127,16 @@ fn sdk_timestamp_rejects_values_outside_nostr_created_at_range() { #[test] fn sdk_partial_local_mutation_error_is_sanitized() { - let error = RadrootsSdkError::partial_local_mutation( - true, - false, - RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey, + let event_id = "a".repeat(64); + let error = RadrootsSdkError::partial_outbox_enqueue_mutation( + event_id, + "listing.publish.v1", + "abcdef123456", ); let message = error.to_string(); + assert!(message.contains("listing.publish.v1")); + assert!(message.contains("abcdef123456")); assert!(message.contains("stored=true")); assert!(message.contains("queued=false")); assert!(!message.contains("sig")); diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs @@ -168,9 +168,7 @@ async fn enqueue_listing(sdk: &RadrootsSdk, d_tag: &str, title: &str, relays: &[ ) .await .expect("enqueue") - .local .outbox_event_id - .expect("outbox event") } #[tokio::test]