commit 86c6fa3219fa158b888a01c50d3f8ecf5541bf0c
parent e07290ddb2458f047f4e86041ee01fddc7a62e79
Author: triesap <tyson@radroots.org>
Date: Sat, 4 Jul 2026 01:01:24 +0000
trade: make propose request product-shaped
- replace common proposal DTO protocol order input with product fields
- build canonical order request internally from actor and intent
- require explicit privacy confirmation for public proposal notes
- add proposal privacy and source-boundary coverage
Diffstat:
4 files changed, 310 insertions(+), 94 deletions(-)
diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs
@@ -32,13 +32,12 @@ use radroots_event_store::{RadrootsEventIngest, RadrootsStoredEvent, RadrootsSto
use radroots_events::{
RadrootsNostrEvent,
contract::RadrootsActorRole,
- ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey},
+ ids::RadrootsEventId,
kinds::{
KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST,
KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL,
KIND_TRADE_TRANSITION_PROOF_RESULT, KIND_TRADE_VALIDATION_RECEIPT,
},
- order::RadrootsOrderEconomics,
tags::{TAG_D, TAG_E, TAG_P},
};
#[cfg(any(feature = "signer-adapters", test))]
@@ -52,6 +51,11 @@ use radroots_events::{
RadrootsOrderRevisionDecision, RadrootsOrderRevisionOutcome, RadrootsOrderRevisionProposal,
},
};
+#[cfg(any(feature = "runtime", feature = "signer-adapters", test))]
+use radroots_events::{
+ ids::{RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey},
+ order::RadrootsOrderEconomics,
+};
#[cfg(feature = "runtime")]
use radroots_events_codec::order::{
order_cancellation_from_event, order_decision_from_event, order_request_from_event,
@@ -1029,7 +1033,12 @@ pub struct TradeProposeRequest {
#[serde(serialize_with = "crate::actor_json::serialize_actor_context")]
pub actor: RadrootsActorContext,
pub listing_event: RadrootsNostrEventPtr,
- pub order: RadrootsOrderRequest,
+ pub order_id: RadrootsOrderId,
+ pub listing_addr: RadrootsListingAddress,
+ pub seller_pubkey: RadrootsPublicKey,
+ pub items: Vec<RadrootsOrderItem>,
+ pub economics: RadrootsOrderEconomics,
+ pub public_note: Option<String>,
pub target_relays: RelayResolutionPolicy,
pub publish_mode: PublishMode,
pub ack_policy: AckPolicy,
@@ -1043,7 +1052,11 @@ impl TradeProposeRequest {
pub fn new(
actor: RadrootsActorContext,
listing_event: RadrootsNostrEventPtr,
- order: RadrootsOrderRequest,
+ order_id: RadrootsOrderId,
+ listing_addr: RadrootsListingAddress,
+ seller_pubkey: RadrootsPublicKey,
+ items: Vec<RadrootsOrderItem>,
+ economics: RadrootsOrderEconomics,
target_relays: RelayResolutionPolicy,
publish_mode: PublishMode,
ack_policy: AckPolicy,
@@ -1051,7 +1064,12 @@ impl TradeProposeRequest {
Self {
actor,
listing_event,
- order,
+ order_id,
+ listing_addr,
+ seller_pubkey,
+ items,
+ economics,
+ public_note: None,
target_relays,
publish_mode,
ack_policy,
@@ -1061,6 +1079,16 @@ impl TradeProposeRequest {
}
}
+ pub fn with_public_note(mut self, public_note: impl Into<String>) -> Self {
+ self.public_note = Some(public_note.into());
+ self
+ }
+
+ pub fn with_optional_public_note(mut self, public_note: Option<String>) -> Self {
+ self.public_note = public_note;
+ self
+ }
+
pub fn with_privacy_confirmation(
mut self,
privacy_confirmation: PrivacyPreflightConfirmation,
@@ -4410,22 +4438,35 @@ impl<'sdk> TradeBuyerClient<'sdk> {
request: TradeProposeRequest,
) -> Result<TradeMutationOutcome<TradeSubmitPlan, TradeSubmitReceipt>, RadrootsSdkError> {
validate_trade_product_publish_policy(request.publish_mode, request.ack_policy)?;
+ require_trade_product_privacy_preflight(
+ "trade.propose",
+ trade_propose_privacy_fields(&request),
+ &request.privacy_confirmation,
+ )?;
let TradeProposeRequest {
actor,
listing_event,
- order,
+ order_id,
+ listing_addr,
+ seller_pubkey,
+ items,
+ economics,
+ public_note: _,
target_relays,
publish_mode,
ack_policy,
- privacy_confirmation,
+ privacy_confirmation: _,
idempotency_key,
created_at,
} = request;
- require_trade_product_privacy_preflight(
- "trade.propose",
- trade_order_request_privacy_fields(&order),
- &privacy_confirmation,
- )?;
+ let order = RadrootsOrderRequest {
+ order_id,
+ listing_addr,
+ buyer_pubkey: actor.pubkey().clone(),
+ seller_pubkey,
+ items,
+ economics,
+ };
let client = trades_client(self.sdk);
let plan = client.prepare_submit(TradeSubmitPrepareRequest {
actor: actor.clone(),
@@ -5292,14 +5333,20 @@ fn require_trade_product_privacy_preflight(
}
#[cfg(feature = "signer-adapters")]
-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]
+fn trade_propose_privacy_fields(request: &TradeProposeRequest) -> Vec<ProductSensitivityField> {
+ let mut fields = Vec::new();
+ if !request.items.is_empty() || !request.economics.items.is_empty() {
+ fields.push(ProductSensitivityField::ProtocolMinimizedInventoryFields);
+ }
+ if let Some(public_note) = request.public_note.as_deref()
+ && !public_note.trim().is_empty()
+ {
+ fields.push(ProductSensitivityField::PublicButSensitiveNotes);
+ if trade_reason_contains_private_coordination(public_note) {
+ fields.push(ProductSensitivityField::SensitiveFulfillmentDetails);
+ }
}
+ fields
}
#[cfg(feature = "signer-adapters")]
diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs
@@ -629,6 +629,26 @@ fn order_request(raw_order_id: &str) -> RadrootsOrderRequest {
}
}
+fn trade_propose_request(
+ raw_order_id: &str,
+ publish_mode: PublishMode,
+ ack_policy: AckPolicy,
+) -> TradeProposeRequest {
+ let order = order_request(raw_order_id);
+ TradeProposeRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ order.order_id,
+ order.listing_addr,
+ order.seller_pubkey,
+ order.items,
+ order.economics,
+ explicit_trade_relays(),
+ publish_mode,
+ ack_policy,
+ )
+}
+
#[cfg(any())]
fn invalid_listing_event_ptr() -> RadrootsNostrEventPtr {
RadrootsNostrEventPtr {
@@ -946,11 +966,8 @@ async fn trade_product_clients_propose_inbox_accept_status_and_resync() {
.trades()
.buyer()
.propose_trade(
- TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-facade-flow"),
- explicit_trade_relays(),
+ trade_propose_request(
+ "trade-product-facade-flow",
PublishMode::EnqueueOnly,
AckPolicy::NoWait,
)
@@ -1107,11 +1124,8 @@ async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() {
.trades()
.buyer()
.propose_trade(
- TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-committed-resync"),
- explicit_trade_relays(),
+ trade_propose_request(
+ "trade-product-committed-resync",
PublishMode::EnqueueOnly,
AckPolicy::NoWait,
)
@@ -1273,11 +1287,8 @@ async fn trade_product_accept_resync_before_mutation_imports_relay_visible_reque
.trades()
.buyer()
.propose_trade(
- TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-resync-before-accept"),
- explicit_trade_relays(),
+ trade_propose_request(
+ "trade-product-resync-before-accept",
PublishMode::EnqueueOnly,
AckPolicy::NoWait,
)
@@ -1398,11 +1409,8 @@ async fn trade_product_revision_status_resync_imports_pending_revision_proposal(
.trades()
.buyer()
.propose_trade(
- TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-resync-before-revision-decision"),
- explicit_trade_relays(),
+ trade_propose_request(
+ "trade-product-resync-before-revision-decision",
PublishMode::EnqueueOnly,
AckPolicy::NoWait,
)
@@ -1529,11 +1537,8 @@ async fn trade_product_accept_local_only_does_not_fetch_relay_evidence() {
.trades()
.buyer()
.propose_trade(
- TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-local-only-no-fetch"),
- explicit_trade_relays(),
+ trade_propose_request(
+ "trade-product-local-only-no-fetch",
PublishMode::EnqueueOnly,
AckPolicy::NoWait,
)
@@ -1601,11 +1606,8 @@ async fn trade_product_accept_require_explicit_evidence_ingests_supplied_request
.trades()
.buyer()
.propose_trade(
- TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-explicit-accept"),
- explicit_trade_relays(),
+ trade_propose_request(
+ "trade-product-explicit-accept",
PublishMode::EnqueueOnly,
AckPolicy::NoWait,
)
@@ -1680,11 +1682,8 @@ async fn trade_product_accept_require_explicit_evidence_rejects_empty_evidence()
.trades()
.buyer()
.propose_trade(
- TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-empty-explicit-accept"),
- explicit_trade_relays(),
+ trade_propose_request(
+ "trade-product-empty-explicit-accept",
PublishMode::EnqueueOnly,
AckPolicy::NoWait,
)
@@ -2546,11 +2545,8 @@ async fn trade_product_propose_idempotency_replays_same_payload_and_conflicts_di
let outbox = RadrootsOutbox::open_file(&storage_paths.outbox_path)
.await
.expect("outbox");
- let request = TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-idempotent"),
- explicit_trade_relays(),
+ let request = trade_propose_request(
+ "trade-product-idempotent",
PublishMode::EnqueueOnly,
AckPolicy::NoWait,
)
@@ -2604,11 +2600,8 @@ async fn trade_product_propose_idempotency_replays_same_payload_and_conflicts_di
.trades()
.buyer()
.propose_trade(
- TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-idempotent-conflict"),
- explicit_trade_relays(),
+ trade_propose_request(
+ "trade-product-idempotent-conflict",
PublishMode::EnqueueOnly,
AckPolicy::NoWait,
)
@@ -2646,6 +2639,152 @@ async fn trade_product_propose_idempotency_replays_same_payload_and_conflicts_di
#[cfg(all(feature = "signer-adapters", feature = "local-signer"))]
#[tokio::test]
+async fn trade_product_propose_requires_public_note_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 missing_confirmation = buyer_sdk
+ .trades()
+ .buyer()
+ .propose_trade(
+ trade_propose_request(
+ "trade-product-propose-public-note",
+ PublishMode::EnqueueOnly,
+ AckPolicy::NoWait,
+ )
+ .with_public_note("please leave at the community table"),
+ )
+ .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.propose");
+ 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(&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,
+ 0
+ );
+}
+
+#[cfg(all(feature = "signer-adapters", feature = "local-signer"))]
+#[tokio::test]
+async fn trade_product_propose_publishes_public_note_after_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 receipt = expect_enqueued(
+ buyer_sdk
+ .trades()
+ .buyer()
+ .propose_trade(
+ trade_propose_request(
+ "trade-product-propose-public-note-confirmed",
+ PublishMode::EnqueueOnly,
+ AckPolicy::NoWait,
+ )
+ .with_public_note("please leave at the community table")
+ .with_privacy_confirmation(public_note_confirmation())
+ .try_with_idempotency_key("trade-product-propose-public-note-confirmed")
+ .expect("proposal idempotency"),
+ )
+ .await
+ .expect("confirmed proposal"),
+ );
+
+ assert_eq!(
+ receipt.order_id.as_str(),
+ "trade-product-propose-public-note-confirmed"
+ );
+ 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(all(feature = "signer-adapters", feature = "local-signer"))]
+#[tokio::test]
+async fn trade_product_propose_blocks_sensitive_fulfillment_note_even_when_confirmed() {
+ 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 forbidden = buyer_sdk
+ .trades()
+ .buyer()
+ .propose_trade(
+ trade_propose_request(
+ "trade-product-propose-sensitive-note",
+ PublishMode::EnqueueOnly,
+ AckPolicy::NoWait,
+ )
+ .with_public_note("pickup address is 123 Farm Lane")
+ .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.propose");
+ 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,
+ 0
+ );
+}
+
+#[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");
@@ -2655,11 +2794,8 @@ async fn trade_product_decline_requires_public_reason_privacy_confirmation() {
.trades()
.buyer()
.propose_trade(
- TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-privacy-decline"),
- explicit_trade_relays(),
+ trade_propose_request(
+ "trade-product-privacy-decline",
PublishMode::EnqueueOnly,
AckPolicy::NoWait,
)
@@ -2762,11 +2898,8 @@ async fn trade_product_cancel_blocks_sensitive_fulfillment_reason_before_mutatio
.trades()
.buyer()
.propose_trade(
- TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-privacy-cancel"),
- explicit_trade_relays(),
+ trade_propose_request(
+ "trade-product-privacy-cancel",
PublishMode::EnqueueOnly,
AckPolicy::NoWait,
)
@@ -2831,11 +2964,8 @@ async fn trade_product_cancel_enqueues_with_locator_and_updates_status() {
.trades()
.buyer()
.propose_trade(
- TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-cancel"),
- explicit_trade_relays(),
+ trade_propose_request(
+ "trade-product-cancel",
PublishMode::EnqueueOnly,
AckPolicy::NoWait,
)
@@ -2899,11 +3029,8 @@ async fn trade_product_revision_lifecycle_uses_locator_and_updates_status() {
.trades()
.buyer()
.propose_trade(
- TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-revision"),
- explicit_trade_relays(),
+ trade_propose_request(
+ "trade-product-revision",
PublishMode::EnqueueOnly,
AckPolicy::NoWait,
)
@@ -2996,11 +3123,8 @@ async fn trade_product_propose_dry_run_returns_plan_without_local_side_effects()
let outcome = sdk
.trades()
.buyer()
- .propose_trade(TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-dry-run"),
- explicit_trade_relays(),
+ .propose_trade(trade_propose_request(
+ "trade-product-dry-run",
PublishMode::DryRun,
AckPolicy::NoWait,
))
diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs
@@ -1058,6 +1058,34 @@ fn trade_product_facade_feature_gates_are_explicit() {
}
#[test]
+fn trade_propose_request_stays_product_shaped() {
+ let source = read_source(
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("src/orders_runtime.rs")
+ .as_path(),
+ );
+ let request = struct_block(source.as_str(), "TradeProposeRequest");
+
+ assert!(
+ !request.contains("RadrootsOrderRequest"),
+ "TradeProposeRequest must not expose protocol-shaped order request input"
+ );
+ for required_field in [
+ "pub order_id: RadrootsOrderId",
+ "pub listing_addr: RadrootsListingAddress",
+ "pub seller_pubkey: RadrootsPublicKey",
+ "pub items: Vec<RadrootsOrderItem>",
+ "pub economics: RadrootsOrderEconomics",
+ "pub public_note: Option<String>",
+ ] {
+ assert!(
+ request.contains(required_field),
+ "TradeProposeRequest must expose product field `{required_field}`"
+ );
+ }
+}
+
+#[test]
fn dvm_client_surface_is_inventory_guarded() {
let source = read_source(
Path::new(env!("CARGO_MANIFEST_DIR"))
diff --git a/crates/sdk/tests/trade_product_publish_runtime.rs b/crates/sdk/tests/trade_product_publish_runtime.rs
@@ -204,6 +204,26 @@ fn order_request(raw_order_id: &str) -> RadrootsOrderRequest {
}
}
+fn trade_propose_request(
+ raw_order_id: &str,
+ publish_mode: PublishMode,
+ ack_policy: AckPolicy,
+) -> TradeProposeRequest {
+ let order = order_request(raw_order_id);
+ TradeProposeRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ order.order_id,
+ order.listing_addr,
+ order.seller_pubkey,
+ order.items,
+ order.economics,
+ explicit_trade_relays(),
+ publish_mode,
+ ack_policy,
+ )
+}
+
fn explicit_trade_relays() -> RelayResolutionPolicy {
RelayResolutionPolicy::explicit(
SdkRelayTargetSet::new([RELAY], SdkRelayUrlPolicy::Public).expect("target relays"),
@@ -233,11 +253,8 @@ async fn trade_product_propose_enqueue_and_publish_uses_ack_policy() {
.trades()
.buyer()
.propose_trade(
- TradeProposeRequest::new(
- buyer_actor(),
- listing_event_ptr(),
- order_request("trade-product-publish"),
- explicit_trade_relays(),
+ trade_propose_request(
+ "trade-product-publish",
PublishMode::EnqueueAndPublish,
AckPolicy::AtLeastOneRelay,
)