lib

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

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:
Mcrates/sdk/src/orders_runtime.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/sdk/tests/orders_runtime.rs | 264++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mcrates/sdk/tests/source_boundary.rs | 28++++++++++++++++++++++++++++
Mcrates/sdk/tests/trade_product_publish_runtime.rs | 27++++++++++++++++++++++-----
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, )