commit e2a22515e1ff15baca756071ec5a0df082cc9c19
parent c5a99864adedac481944ab6a14a7ee9709acc2dd
Author: triesap <tyson@radroots.org>
Date: Tue, 30 Jun 2026 04:06:02 +0000
sdk: enforce product trade privacy preflight
Diffstat:
7 files changed, 491 insertions(+), 9 deletions(-)
diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs
@@ -2,6 +2,8 @@
use std::{fmt, path::PathBuf};
#[cfg(feature = "runtime")]
+use crate::privacy::{PrivacyPreflightStatus, ProductSensitivityField};
+#[cfg(feature = "runtime")]
use radroots_trade::identity::RadrootsTradeLocator;
#[cfg(feature = "runtime")]
use serde_json::{Value, json};
@@ -155,6 +157,11 @@ pub enum RadrootsSdkError {
locator: RadrootsTradeLocator,
candidates: Vec<RadrootsTradeLocator>,
},
+ PrivacyPreflight {
+ operation: String,
+ status: PrivacyPreflightStatus,
+ fields: Vec<ProductSensitivityField>,
+ },
ProductSyncUnsupported {
operation: &'static str,
required_feature: &'static str,
@@ -219,6 +226,7 @@ impl RadrootsSdkError {
Self::TradeStatusLimitInvalid { .. } => "trade_status_limit_invalid",
Self::InvalidTradeId { .. } => "invalid_trade_id",
Self::TradeAmbiguous { .. } => "trade_ambiguous",
+ Self::PrivacyPreflight { .. } => "privacy_preflight",
Self::ProductSyncUnsupported { .. } => "product_sync_unsupported",
Self::ProductSyncRelaySetupFailure { .. } => "product_sync_relay_setup_failure",
Self::Authority { .. } => "authority",
@@ -273,6 +281,7 @@ impl RadrootsSdkError {
| Self::TradeStatusLimitInvalid { .. }
| Self::InvalidTradeId { .. }
| Self::TradeAmbiguous { .. }
+ | Self::PrivacyPreflight { .. }
| Self::SignerProtocol { .. }
| Self::SignerAuthChallengePending { .. }
| Self::InvalidRequest { .. }
@@ -344,6 +353,7 @@ impl RadrootsSdkError {
vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey]
}
Self::TradeAmbiguous { .. } => vec![RadrootsSdkRecoveryAction::SelectTradeRoot],
+ Self::PrivacyPreflight { .. } => vec![RadrootsSdkRecoveryAction::FixRequest],
Self::ProductSyncUnsupported { .. } => {
vec![RadrootsSdkRecoveryAction::EnableRequiredFeature]
}
@@ -431,6 +441,15 @@ impl RadrootsSdkError {
"locator": locator,
"candidates": candidates
}),
+ Self::PrivacyPreflight {
+ operation,
+ status,
+ fields,
+ } => json!({
+ "operation": operation,
+ "status": status,
+ "fields": fields
+ }),
Self::ProductSyncUnsupported {
operation,
required_feature,
@@ -620,6 +639,15 @@ impl fmt::Display for RadrootsSdkError {
locator.order_id().as_str(),
candidates.len()
),
+ Self::PrivacyPreflight {
+ operation,
+ status,
+ fields,
+ } => write!(
+ f,
+ "sdk privacy preflight failed for {operation}: status={status:?}, field_count={}",
+ fields.len()
+ ),
Self::ProductSyncUnsupported {
operation,
required_feature,
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -123,7 +123,8 @@ pub use crate::orders_runtime::{
};
#[cfg(feature = "runtime")]
pub use crate::privacy::{
- PrivacyPreflightReceipt, PrivacyPreflightStatus, ProductSensitivityField,
+ PrivacyPreflightConfirmation, PrivacyPreflightReceipt, PrivacyPreflightStatus,
+ ProductSensitivityField,
};
#[cfg(feature = "runtime")]
pub use crate::product_clients::{
diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs
@@ -2,10 +2,11 @@
use crate::workflow_runtime::enqueue_configured_signed_workflow;
#[cfg(feature = "runtime")]
use crate::{
- AckPolicy, PublishMode, PushOutboxReceipt, PushOutboxRequest, RadrootsSdkError,
- RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, RelayResolutionPolicy, SdkIdempotencyKey,
- SdkMutationState, TradeBuyerClient, TradeResyncClient, TradeSellerClient, TradeStatusClient,
- TradesClient, order, workflow_runtime::SdkWorkflowEnqueueRequest,
+ AckPolicy, PrivacyPreflightConfirmation, PrivacyPreflightReceipt, ProductSensitivityField,
+ PublishMode, PushOutboxReceipt, PushOutboxRequest, RadrootsSdkError, RadrootsSdkRecoveryAction,
+ RadrootsSdkTimestamp, RelayResolutionPolicy, SdkIdempotencyKey, SdkMutationState,
+ TradeBuyerClient, TradeResyncClient, TradeSellerClient, TradeStatusClient, TradesClient, order,
+ workflow_runtime::SdkWorkflowEnqueueRequest,
};
#[cfg(all(feature = "runtime", test))]
use crate::{SdkRelayUrlPolicy, workflow_runtime::enqueue_signed_workflow};
@@ -966,6 +967,7 @@ pub struct TradeProposeRequest {
pub target_relays: RelayResolutionPolicy,
pub publish_mode: PublishMode,
pub ack_policy: AckPolicy,
+ pub privacy_confirmation: PrivacyPreflightConfirmation,
pub idempotency_key: Option<SdkIdempotencyKey>,
pub created_at: Option<RadrootsSdkTimestamp>,
}
@@ -987,11 +989,20 @@ impl TradeProposeRequest {
target_relays,
publish_mode,
ack_policy,
+ privacy_confirmation: PrivacyPreflightConfirmation::new(),
idempotency_key: None,
created_at: None,
}
}
+ pub fn with_privacy_confirmation(
+ mut self,
+ privacy_confirmation: PrivacyPreflightConfirmation,
+ ) -> Self {
+ self.privacy_confirmation = privacy_confirmation;
+ self
+ }
+
pub fn try_with_idempotency_key(
mut self,
idempotency_key: impl AsRef<str>,
@@ -1017,6 +1028,7 @@ pub struct TradeAcceptRequest {
pub target_relays: RelayResolutionPolicy,
pub publish_mode: PublishMode,
pub ack_policy: AckPolicy,
+ pub privacy_confirmation: PrivacyPreflightConfirmation,
pub idempotency_key: Option<SdkIdempotencyKey>,
pub created_at: Option<RadrootsSdkTimestamp>,
}
@@ -1038,11 +1050,20 @@ impl TradeAcceptRequest {
target_relays,
publish_mode,
ack_policy,
+ privacy_confirmation: PrivacyPreflightConfirmation::new(),
idempotency_key: None,
created_at: None,
}
}
+ pub fn with_privacy_confirmation(
+ mut self,
+ privacy_confirmation: PrivacyPreflightConfirmation,
+ ) -> Self {
+ self.privacy_confirmation = privacy_confirmation;
+ self
+ }
+
pub fn try_with_idempotency_key(
mut self,
idempotency_key: impl AsRef<str>,
@@ -1068,6 +1089,7 @@ pub struct TradeDeclineRequest {
pub target_relays: RelayResolutionPolicy,
pub publish_mode: PublishMode,
pub ack_policy: AckPolicy,
+ pub privacy_confirmation: PrivacyPreflightConfirmation,
pub idempotency_key: Option<SdkIdempotencyKey>,
pub created_at: Option<RadrootsSdkTimestamp>,
}
@@ -1089,11 +1111,20 @@ impl TradeDeclineRequest {
target_relays,
publish_mode,
ack_policy,
+ privacy_confirmation: PrivacyPreflightConfirmation::new(),
idempotency_key: None,
created_at: None,
}
}
+ pub fn with_privacy_confirmation(
+ mut self,
+ privacy_confirmation: PrivacyPreflightConfirmation,
+ ) -> Self {
+ self.privacy_confirmation = privacy_confirmation;
+ self
+ }
+
pub fn try_with_idempotency_key(
mut self,
idempotency_key: impl AsRef<str>,
@@ -1119,6 +1150,7 @@ pub struct TradeCancelRequest {
pub target_relays: RelayResolutionPolicy,
pub publish_mode: PublishMode,
pub ack_policy: AckPolicy,
+ pub privacy_confirmation: PrivacyPreflightConfirmation,
pub idempotency_key: Option<SdkIdempotencyKey>,
pub created_at: Option<RadrootsSdkTimestamp>,
}
@@ -1140,11 +1172,20 @@ impl TradeCancelRequest {
target_relays,
publish_mode,
ack_policy,
+ privacy_confirmation: PrivacyPreflightConfirmation::new(),
idempotency_key: None,
created_at: None,
}
}
+ pub fn with_privacy_confirmation(
+ mut self,
+ privacy_confirmation: PrivacyPreflightConfirmation,
+ ) -> Self {
+ self.privacy_confirmation = privacy_confirmation;
+ self
+ }
+
pub fn try_with_idempotency_key(
mut self,
idempotency_key: impl AsRef<str>,
@@ -1173,6 +1214,7 @@ pub struct TradeRevisionProposalRequest {
pub target_relays: RelayResolutionPolicy,
pub publish_mode: PublishMode,
pub ack_policy: AckPolicy,
+ pub privacy_confirmation: PrivacyPreflightConfirmation,
pub idempotency_key: Option<SdkIdempotencyKey>,
pub created_at: Option<RadrootsSdkTimestamp>,
}
@@ -1200,11 +1242,20 @@ impl TradeRevisionProposalRequest {
target_relays,
publish_mode,
ack_policy,
+ privacy_confirmation: PrivacyPreflightConfirmation::new(),
idempotency_key: None,
created_at: None,
}
}
+ pub fn with_privacy_confirmation(
+ mut self,
+ privacy_confirmation: PrivacyPreflightConfirmation,
+ ) -> Self {
+ self.privacy_confirmation = privacy_confirmation;
+ self
+ }
+
pub fn try_with_idempotency_key(
mut self,
idempotency_key: impl AsRef<str>,
@@ -1231,6 +1282,7 @@ pub struct TradeRevisionDecisionRequest {
pub target_relays: RelayResolutionPolicy,
pub publish_mode: PublishMode,
pub ack_policy: AckPolicy,
+ pub privacy_confirmation: PrivacyPreflightConfirmation,
pub idempotency_key: Option<SdkIdempotencyKey>,
pub created_at: Option<RadrootsSdkTimestamp>,
}
@@ -1254,11 +1306,20 @@ impl TradeRevisionDecisionRequest {
target_relays,
publish_mode,
ack_policy,
+ privacy_confirmation: PrivacyPreflightConfirmation::new(),
idempotency_key: None,
created_at: None,
}
}
+ pub fn with_privacy_confirmation(
+ mut self,
+ privacy_confirmation: PrivacyPreflightConfirmation,
+ ) -> Self {
+ self.privacy_confirmation = privacy_confirmation;
+ self
+ }
+
pub fn try_with_idempotency_key(
mut self,
idempotency_key: impl AsRef<str>,
@@ -2505,9 +2566,15 @@ impl<'sdk> TradeBuyerClient<'sdk> {
target_relays,
publish_mode,
ack_policy,
+ privacy_confirmation,
idempotency_key,
created_at,
} = request;
+ require_trade_product_privacy_preflight(
+ "trade.propose",
+ trade_order_request_privacy_fields(&order),
+ &privacy_confirmation,
+ )?;
let client = trades_client(self.sdk);
let plan = client.prepare_submit(TradeSubmitPrepareRequest {
actor: actor.clone(),
@@ -2553,6 +2620,7 @@ impl<'sdk> TradeBuyerClient<'sdk> {
target_relays,
publish_mode,
ack_policy,
+ privacy_confirmation,
idempotency_key,
created_at,
} = request;
@@ -2564,6 +2632,11 @@ impl<'sdk> TradeBuyerClient<'sdk> {
seller_pubkey: context.seller_pubkey.clone(),
reason,
};
+ require_trade_product_privacy_preflight(
+ "trade.cancel",
+ trade_reason_privacy_fields(&cancellation.reason),
+ &privacy_confirmation,
+ )?;
let client = trades_client(self.sdk);
let plan = client.prepare_cancellation(TradeCancellationPrepareRequest {
actor: actor.clone(),
@@ -2632,9 +2705,15 @@ impl<'sdk> TradeBuyerClient<'sdk> {
target_relays,
publish_mode,
ack_policy,
+ privacy_confirmation,
idempotency_key,
created_at,
} = request;
+ require_trade_product_privacy_preflight(
+ "trade.revision_decision",
+ trade_revision_decision_privacy_fields(&decision),
+ &privacy_confirmation,
+ )?;
let context = trade_mutation_context(self.sdk, locator, "trade.revision_decision").await?;
let previous_event_id = context.pending_revision_event_id.clone().ok_or_else(|| {
RadrootsSdkError::InvalidRequest {
@@ -2770,6 +2849,7 @@ impl<'sdk> TradeSellerClient<'sdk> {
target_relays,
publish_mode,
ack_policy,
+ privacy_confirmation,
idempotency_key,
created_at,
} = request;
@@ -2783,6 +2863,11 @@ impl<'sdk> TradeSellerClient<'sdk> {
inventory_commitments,
},
};
+ require_trade_product_privacy_preflight(
+ "trade.accept",
+ trade_decision_privacy_fields(&decision),
+ &privacy_confirmation,
+ )?;
let client = trades_client(self.sdk);
let plan = client.prepare_decision(TradeDecisionPrepareRequest {
actor: actor.clone(),
@@ -2826,6 +2911,7 @@ impl<'sdk> TradeSellerClient<'sdk> {
target_relays,
publish_mode,
ack_policy,
+ privacy_confirmation,
idempotency_key,
created_at,
} = request;
@@ -2837,6 +2923,11 @@ impl<'sdk> TradeSellerClient<'sdk> {
seller_pubkey: context.seller_pubkey.clone(),
decision: RadrootsOrderDecisionOutcome::Declined { reason },
};
+ require_trade_product_privacy_preflight(
+ "trade.decline",
+ trade_decision_privacy_fields(&decision),
+ &privacy_confirmation,
+ )?;
let client = trades_client(self.sdk);
let plan = client.prepare_decision(TradeDecisionPrepareRequest {
actor: actor.clone(),
@@ -2885,6 +2976,7 @@ impl<'sdk> TradeSellerClient<'sdk> {
target_relays,
publish_mode,
ack_policy,
+ privacy_confirmation,
idempotency_key,
created_at,
} = request;
@@ -2901,6 +2993,11 @@ impl<'sdk> TradeSellerClient<'sdk> {
economics,
reason,
};
+ require_trade_product_privacy_preflight(
+ "trade.propose_revision",
+ trade_revision_proposal_privacy_fields(&proposal),
+ &privacy_confirmation,
+ )?;
let client = trades_client(self.sdk);
let plan = client.prepare_revision_proposal(TradeRevisionProposalPrepareRequest {
actor: actor.clone(),
@@ -3090,6 +3187,93 @@ fn validate_trade_product_publish_policy(
}
#[cfg(feature = "runtime")]
+fn require_trade_product_privacy_preflight(
+ operation: &'static str,
+ fields: Vec<ProductSensitivityField>,
+ confirmation: &PrivacyPreflightConfirmation,
+) -> Result<(), RadrootsSdkError> {
+ PrivacyPreflightReceipt::evaluate(fields)
+ .require_public_publish_allowed(operation, confirmation)
+}
+
+#[cfg(feature = "runtime")]
+fn trade_order_request_privacy_fields(
+ order: &RadrootsOrderRequest,
+) -> Vec<ProductSensitivityField> {
+ if order.items.is_empty() && order.economics.items.is_empty() {
+ Vec::new()
+ } else {
+ vec![ProductSensitivityField::ProtocolMinimizedInventoryFields]
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn trade_decision_privacy_fields(decision: &RadrootsOrderDecision) -> Vec<ProductSensitivityField> {
+ match &decision.decision {
+ RadrootsOrderDecisionOutcome::Accepted {
+ inventory_commitments,
+ } if !inventory_commitments.is_empty() => {
+ vec![ProductSensitivityField::ProtocolMinimizedInventoryFields]
+ }
+ RadrootsOrderDecisionOutcome::Accepted { .. } => Vec::new(),
+ RadrootsOrderDecisionOutcome::Declined { reason } => trade_reason_privacy_fields(reason),
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn trade_revision_proposal_privacy_fields(
+ proposal: &RadrootsOrderRevisionProposal,
+) -> Vec<ProductSensitivityField> {
+ let mut fields = trade_reason_privacy_fields(&proposal.reason);
+ if !proposal.items.is_empty() || !proposal.economics.items.is_empty() {
+ fields.push(ProductSensitivityField::ProtocolMinimizedInventoryFields);
+ }
+ fields
+}
+
+#[cfg(feature = "runtime")]
+fn trade_revision_decision_privacy_fields(
+ decision: &RadrootsOrderRevisionOutcome,
+) -> Vec<ProductSensitivityField> {
+ match decision {
+ RadrootsOrderRevisionOutcome::Accepted => Vec::new(),
+ RadrootsOrderRevisionOutcome::Declined { reason } => trade_reason_privacy_fields(reason),
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn trade_reason_privacy_fields(reason: &str) -> Vec<ProductSensitivityField> {
+ if reason.trim().is_empty() {
+ return Vec::new();
+ }
+ let mut fields = vec![ProductSensitivityField::PublicButSensitiveNotes];
+ if trade_reason_contains_private_coordination(reason) {
+ fields.push(ProductSensitivityField::SensitiveFulfillmentDetails);
+ }
+ fields
+}
+
+#[cfg(feature = "runtime")]
+fn trade_reason_contains_private_coordination(reason: &str) -> bool {
+ let reason = reason.to_ascii_lowercase();
+ [
+ "pickup address",
+ "delivery address",
+ "street address",
+ "exact location",
+ "private location",
+ "gate code",
+ "door code",
+ "latitude",
+ "longitude",
+ " gps",
+ "farm gate",
+ ]
+ .iter()
+ .any(|marker| reason.contains(marker))
+}
+
+#[cfg(feature = "runtime")]
fn validate_trade_enqueue_policy(
publish_mode: PublishMode,
ack_policy: AckPolicy,
diff --git a/crates/sdk/src/privacy.rs b/crates/sdk/src/privacy.rs
@@ -27,6 +27,30 @@ pub struct PrivacyPreflightReceipt {
}
#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
+pub struct PrivacyPreflightConfirmation {
+ pub fields: Vec<ProductSensitivityField>,
+}
+
+#[cfg(feature = "runtime")]
+impl PrivacyPreflightConfirmation {
+ pub fn new() -> Self {
+ Self { fields: Vec::new() }
+ }
+
+ pub fn confirm(mut self, field: ProductSensitivityField) -> Self {
+ self.fields.push(field);
+ self.fields.sort();
+ self.fields.dedup();
+ self
+ }
+
+ pub fn confirms(&self, field: ProductSensitivityField) -> bool {
+ self.fields.contains(&field)
+ }
+}
+
+#[cfg(feature = "runtime")]
impl PrivacyPreflightReceipt {
pub fn evaluate<I>(fields: I) -> Self
where
@@ -53,12 +77,49 @@ impl PrivacyPreflightReceipt {
};
Self { status, fields }
}
+
+ pub fn require_public_publish_allowed(
+ &self,
+ operation: impl Into<String>,
+ confirmation: &PrivacyPreflightConfirmation,
+ ) -> Result<(), crate::error::RadrootsSdkError> {
+ match self.status {
+ PrivacyPreflightStatus::Ok => Ok(()),
+ PrivacyPreflightStatus::ForbiddenPublicFields => {
+ Err(crate::error::RadrootsSdkError::PrivacyPreflight {
+ operation: operation.into(),
+ status: self.status,
+ fields: self.fields.clone(),
+ })
+ }
+ PrivacyPreflightStatus::ExplicitConfirmationRequired => {
+ let missing_fields = self
+ .fields
+ .iter()
+ .copied()
+ .filter(|field| !confirmation.confirms(*field))
+ .collect::<Vec<_>>();
+ if missing_fields.is_empty() {
+ Ok(())
+ } else {
+ Err(crate::error::RadrootsSdkError::PrivacyPreflight {
+ operation: operation.into(),
+ status: self.status,
+ fields: missing_fields,
+ })
+ }
+ }
+ }
+ }
}
#[cfg(test)]
#[cfg(feature = "runtime")]
mod tests {
- use super::{PrivacyPreflightReceipt, PrivacyPreflightStatus, ProductSensitivityField};
+ use super::{
+ PrivacyPreflightConfirmation, PrivacyPreflightReceipt, PrivacyPreflightStatus,
+ ProductSensitivityField,
+ };
#[test]
fn privacy_preflight_classifies_public_sensitivity() {
@@ -83,4 +144,26 @@ mod tests {
PrivacyPreflightStatus::ForbiddenPublicFields
);
}
+
+ #[test]
+ fn privacy_confirmation_allows_only_confirmable_public_fields() {
+ let confirmation = PrivacyPreflightConfirmation::new()
+ .confirm(ProductSensitivityField::PublicButSensitiveNotes);
+ PrivacyPreflightReceipt::evaluate([ProductSensitivityField::PublicButSensitiveNotes])
+ .require_public_publish_allowed("trade.test", &confirmation)
+ .expect("confirmed public note");
+
+ let missing =
+ PrivacyPreflightReceipt::evaluate([ProductSensitivityField::PublicButSensitiveNotes])
+ .require_public_publish_allowed("trade.test", &PrivacyPreflightConfirmation::new())
+ .expect_err("missing confirmation");
+ assert_eq!(missing.code(), "privacy_preflight");
+
+ let forbidden = PrivacyPreflightReceipt::evaluate([
+ ProductSensitivityField::SensitiveFulfillmentDetails,
+ ])
+ .require_public_publish_allowed("trade.test", &confirmation)
+ .expect_err("forbidden cannot be confirmed");
+ assert_eq!(forbidden.code(), "privacy_preflight");
+ }
}
diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs
@@ -31,10 +31,12 @@ use radroots_sdk::{
AckPolicy, PublishMode, RadrootsClient, RadrootsSdkError, RadrootsSdkRecoveryAction,
RadrootsSdkTimestamp, RelayResolutionPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy,
SdkTradeStatusIssue, SdkTradeStatusIssueKind, SdkTradeStatusSource, TRADE_STATUS_DEFAULT_LIMIT,
- TRADE_STATUS_MAX_LIMIT, TradeAcceptRequest, TradeEvidenceIngestRequest, TradeMutationOutcome,
- TradeProposeRequest, TradeRequestEvidenceIngestRequest, TradeResyncRequest,
- TradeSellerInboxRequest, TradeStatusKind, TradeStatusNextActionKind, TradeStatusRequest,
+ TRADE_STATUS_MAX_LIMIT, TradeAcceptRequest, TradeCancelRequest, TradeDeclineRequest,
+ TradeEvidenceIngestRequest, TradeMutationOutcome, TradeProposeRequest,
+ TradeRequestEvidenceIngestRequest, TradeResyncRequest, TradeSellerInboxRequest,
+ TradeStatusKind, TradeStatusNextActionKind, TradeStatusRequest,
};
+use radroots_sdk::{PrivacyPreflightConfirmation, PrivacyPreflightStatus, ProductSensitivityField};
#[cfg(all(feature = "signer-adapters", feature = "local-signer"))]
use radroots_sdk::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider};
use radroots_trade::order::RadrootsOrderIssue;
@@ -455,6 +457,10 @@ fn explicit_trade_relays() -> RelayResolutionPolicy {
)
}
+fn public_note_confirmation() -> PrivacyPreflightConfirmation {
+ PrivacyPreflightConfirmation::new().confirm(ProductSensitivityField::PublicButSensitiveNotes)
+}
+
fn expect_enqueued<Plan, Receipt>(outcome: TradeMutationOutcome<Plan, Receipt>) -> Receipt {
match outcome {
TradeMutationOutcome::Enqueued { receipt } => receipt,
@@ -950,6 +956,179 @@ async fn trade_product_clients_propose_inbox_accept_status_and_resync() {
);
}
+#[cfg(all(feature = "signer-adapters", feature = "local-signer"))]
+#[tokio::test]
+async fn trade_product_decline_requires_public_reason_privacy_confirmation() {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let storage_root = tempdir.path().join("sdk");
+ let buyer_sdk = directory_sdk_with_signer(storage_root.as_path(), BUYER_SECRET_KEY_HEX).await;
+ let propose_receipt = expect_enqueued(
+ buyer_sdk
+ .trades()
+ .buyer()
+ .propose_trade(
+ TradeProposeRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ order_request("trade-product-privacy-decline"),
+ explicit_trade_relays(),
+ PublishMode::EnqueueOnly,
+ AckPolicy::NoWait,
+ )
+ .try_with_idempotency_key("trade-product-privacy-decline-propose")
+ .expect("propose idempotency"),
+ )
+ .await
+ .expect("propose trade"),
+ );
+ let seller_sdk = directory_sdk_with_signer(storage_root.as_path(), SELLER_SECRET_KEY_HEX).await;
+
+ let missing_confirmation = seller_sdk
+ .trades()
+ .seller()
+ .decline_trade(TradeDeclineRequest::new(
+ seller_actor(),
+ propose_receipt.locator.clone(),
+ "sold elsewhere",
+ explicit_trade_relays(),
+ PublishMode::EnqueueOnly,
+ AckPolicy::NoWait,
+ ))
+ .await
+ .expect_err("missing public note confirmation");
+
+ let RadrootsSdkError::PrivacyPreflight {
+ operation,
+ status,
+ fields,
+ } = &missing_confirmation
+ else {
+ panic!("expected privacy preflight error");
+ };
+ assert_eq!(operation, "trade.decline");
+ assert_eq!(
+ *status,
+ PrivacyPreflightStatus::ExplicitConfirmationRequired
+ );
+ assert_eq!(fields, &[ProductSensitivityField::PublicButSensitiveNotes]);
+ assert_eq!(missing_confirmation.code(), "privacy_preflight");
+ assert_eq!(
+ missing_confirmation.detail_json()["detail"]["fields"][0],
+ "public_but_sensitive_notes"
+ );
+ let store =
+ RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").event_store_path)
+ .await
+ .expect("event store");
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 1
+ );
+
+ let decline_receipt = expect_enqueued(
+ seller_sdk
+ .trades()
+ .seller()
+ .decline_trade(
+ TradeDeclineRequest::new(
+ seller_actor(),
+ propose_receipt.locator.clone(),
+ "sold elsewhere",
+ explicit_trade_relays(),
+ PublishMode::EnqueueOnly,
+ AckPolicy::NoWait,
+ )
+ .with_privacy_confirmation(public_note_confirmation())
+ .try_with_idempotency_key("trade-product-privacy-decline-confirmed")
+ .expect("decline idempotency"),
+ )
+ .await
+ .expect("confirmed decline"),
+ );
+ let status = seller_sdk
+ .trades()
+ .status(TradeStatusRequest::new(propose_receipt.locator))
+ .await
+ .expect("status");
+ assert_eq!(status.status, TradeStatusKind::Declined);
+ assert_eq!(
+ status.decision_event_id,
+ Some(decline_receipt.signed_event_id)
+ );
+}
+
+#[cfg(all(feature = "signer-adapters", feature = "local-signer"))]
+#[tokio::test]
+async fn trade_product_cancel_blocks_sensitive_fulfillment_reason_before_mutation() {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let storage_root = tempdir.path().join("sdk");
+ let buyer_sdk = directory_sdk_with_signer(storage_root.as_path(), BUYER_SECRET_KEY_HEX).await;
+ let propose_receipt = expect_enqueued(
+ buyer_sdk
+ .trades()
+ .buyer()
+ .propose_trade(
+ TradeProposeRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ order_request("trade-product-privacy-cancel"),
+ explicit_trade_relays(),
+ PublishMode::EnqueueOnly,
+ AckPolicy::NoWait,
+ )
+ .try_with_idempotency_key("trade-product-privacy-cancel-propose")
+ .expect("propose idempotency"),
+ )
+ .await
+ .expect("propose trade"),
+ );
+
+ let forbidden = buyer_sdk
+ .trades()
+ .buyer()
+ .cancel_trade(
+ TradeCancelRequest::new(
+ buyer_actor(),
+ propose_receipt.locator,
+ "pickup address is 123 Farm Lane",
+ explicit_trade_relays(),
+ PublishMode::EnqueueOnly,
+ AckPolicy::NoWait,
+ )
+ .with_privacy_confirmation(public_note_confirmation()),
+ )
+ .await
+ .expect_err("forbidden public fulfillment details");
+
+ let RadrootsSdkError::PrivacyPreflight {
+ operation,
+ status,
+ fields,
+ } = &forbidden
+ else {
+ panic!("expected privacy preflight error");
+ };
+ assert_eq!(operation, "trade.cancel");
+ assert_eq!(*status, PrivacyPreflightStatus::ForbiddenPublicFields);
+ assert!(fields.contains(&ProductSensitivityField::SensitiveFulfillmentDetails));
+ let store =
+ RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").event_store_path)
+ .await
+ .expect("event store");
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 1
+ );
+}
+
#[cfg(feature = "signer-adapters")]
#[tokio::test]
async fn trade_product_propose_dry_run_returns_plan_without_local_side_effects() {
diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs
@@ -108,6 +108,7 @@ const REQUIRED_TRADE_POLICY_EXPORTS: &[&str] = &[
"PublishMode",
"RelayResolutionPolicy",
"SdkTradeIdempotencyRecord",
+ "PrivacyPreflightConfirmation",
"PrivacyPreflightReceipt",
"PrivacyPreflightStatus",
"ProductSensitivityField",
diff --git a/crates/sdk/tests/unit/error_tests.rs b/crates/sdk/tests/unit/error_tests.rs
@@ -2,6 +2,7 @@ use super::{
RadrootsSdkError, RadrootsSdkGeoNamesErrorKind, RadrootsSdkPartialLocalMutationError,
RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, redacted_relay_url,
};
+use crate::privacy::{PrivacyPreflightStatus, ProductSensitivityField};
use radroots_authority::RadrootsAuthorityError;
use radroots_events::contract::RadrootsActorRole;
use radroots_geocoder::{GeoNamesAssetFetcher, GeoNamesBlockingHttpFetcher, GeocoderError};
@@ -356,6 +357,11 @@ fn sdk_error_contract_methods_cover_representative_classes_and_details() {
},
RadrootsSdkError::trade_status_limit_invalid(0, 1, 100),
RadrootsSdkError::invalid_trade_id("bad order", "bad id"),
+ RadrootsSdkError::PrivacyPreflight {
+ operation: "trade.cancel".to_owned(),
+ status: PrivacyPreflightStatus::ExplicitConfirmationRequired,
+ fields: vec![ProductSensitivityField::PublicButSensitiveNotes],
+ },
RadrootsSdkError::ProductSyncUnsupported {
operation: "sync.push_outbox",
required_feature: "relay-runtime",