lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 836e5e7cd2888bbbc5e5d504fb91e6f4ae5855d9
parent 943aa92dad222ee9f707ddc51dbd19acd6e87f93
Author: triesap <tyson@radroots.org>
Date:   Tue, 30 Jun 2026 04:06:02 +0000

sdk: enforce product trade privacy preflight

Diffstat:
Mcrates/sdk/src/error.rs | 28++++++++++++++++++++++++++++
Mcrates/sdk/src/lib.rs | 3++-
Mcrates/sdk/src/orders_runtime.rs | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/sdk/src/privacy.rs | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/orders_runtime.rs | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/sdk/tests/source_boundary.rs | 1+
Mcrates/sdk/tests/unit/error_tests.rs | 6++++++
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",