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:
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]