sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 80b30f33daab6e32a07edcd4bbc856d1778cff38
parent bd39b52795651ade71acb7e71f9740cb02c32627
Author: triesap <tyson@radroots.org>
Date:   Sat,  4 Jul 2026 00:10:43 +0000

workflow: add explicit trade mutation evidence modes

- add evidence modes to trade lifecycle mutation requests
- resolve local, explicit, and resynced mutation evidence in SDK
- split evidence branch fetches into deterministic kind filters
- cover local-only, explicit, and resync mutation behavior

Diffstat:
Mcrates/sdk/src/lib.rs | 2+-
Mcrates/sdk/src/orders_runtime.rs | 335++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/sdk/tests/orders_runtime.rs | 598++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/source_boundary.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 972 insertions(+), 26 deletions(-)

diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -127,7 +127,7 @@ pub use crate::orders_runtime::{ TRADE_REVISION_DECISION_OPERATION_KIND, TRADE_REVISION_PROPOSAL_OPERATION_KIND, TRADE_SUBMIT_OPERATION_KIND, TradeAcceptRequest, TradeCancelRequest, TradeCancellationPlan, TradeCancellationReceipt, TradeDecisionPlan, TradeDecisionReceipt, TradeDeclineRequest, - TradeMutationOutcome, TradeProposeRequest, TradeRevisionDecisionPlan, + TradeEvidenceMode, TradeMutationOutcome, TradeProposeRequest, TradeRevisionDecisionPlan, TradeRevisionDecisionReceipt, TradeRevisionDecisionRequest, TradeRevisionProposalPlan, TradeRevisionProposalReceipt, TradeRevisionProposalRequest, TradeSubmitPlan, TradeSubmitReceipt, TradeWorkflowEnqueueReceipt, TradeWorkflowIdempotencyReceipt, diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs @@ -1001,6 +1001,29 @@ pub enum TradeMutationOutcome<Plan, Receipt> { #[cfg(any(feature = "signer-adapters", test))] #[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum TradeEvidenceMode { + LocalOnly, + ResyncBeforeMutation, + RequireExplicitEvidence { + evidence: Vec<TradeEvidenceIngestRequest>, + }, +} + +#[cfg(any(feature = "signer-adapters", test))] +impl TradeEvidenceMode { + pub fn require_explicit_evidence( + evidence: impl IntoIterator<Item = TradeEvidenceIngestRequest>, + ) -> Self { + Self::RequireExplicitEvidence { + evidence: evidence.into_iter().collect(), + } + } +} + +#[cfg(any(feature = "signer-adapters", test))] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct TradeProposeRequest { #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] @@ -1071,6 +1094,7 @@ pub struct TradeAcceptRequest { pub target_relays: RelayResolutionPolicy, pub publish_mode: PublishMode, pub ack_policy: AckPolicy, + pub evidence_mode: TradeEvidenceMode, pub privacy_confirmation: PrivacyPreflightConfirmation, pub idempotency_key: Option<SdkIdempotencyKey>, pub created_at: Option<RadrootsSdkTimestamp>, @@ -1085,6 +1109,7 @@ impl TradeAcceptRequest { target_relays: RelayResolutionPolicy, publish_mode: PublishMode, ack_policy: AckPolicy, + evidence_mode: TradeEvidenceMode, ) -> Self { Self { actor, @@ -1093,6 +1118,7 @@ impl TradeAcceptRequest { target_relays, publish_mode, ack_policy, + evidence_mode, privacy_confirmation: PrivacyPreflightConfirmation::new(), idempotency_key: None, created_at: None, @@ -1132,6 +1158,7 @@ pub struct TradeDeclineRequest { pub target_relays: RelayResolutionPolicy, pub publish_mode: PublishMode, pub ack_policy: AckPolicy, + pub evidence_mode: TradeEvidenceMode, pub privacy_confirmation: PrivacyPreflightConfirmation, pub idempotency_key: Option<SdkIdempotencyKey>, pub created_at: Option<RadrootsSdkTimestamp>, @@ -1146,6 +1173,7 @@ impl TradeDeclineRequest { target_relays: RelayResolutionPolicy, publish_mode: PublishMode, ack_policy: AckPolicy, + evidence_mode: TradeEvidenceMode, ) -> Self { Self { actor, @@ -1154,6 +1182,7 @@ impl TradeDeclineRequest { target_relays, publish_mode, ack_policy, + evidence_mode, privacy_confirmation: PrivacyPreflightConfirmation::new(), idempotency_key: None, created_at: None, @@ -1193,6 +1222,7 @@ pub struct TradeCancelRequest { pub target_relays: RelayResolutionPolicy, pub publish_mode: PublishMode, pub ack_policy: AckPolicy, + pub evidence_mode: TradeEvidenceMode, pub privacy_confirmation: PrivacyPreflightConfirmation, pub idempotency_key: Option<SdkIdempotencyKey>, pub created_at: Option<RadrootsSdkTimestamp>, @@ -1207,6 +1237,7 @@ impl TradeCancelRequest { target_relays: RelayResolutionPolicy, publish_mode: PublishMode, ack_policy: AckPolicy, + evidence_mode: TradeEvidenceMode, ) -> Self { Self { actor, @@ -1215,6 +1246,7 @@ impl TradeCancelRequest { target_relays, publish_mode, ack_policy, + evidence_mode, privacy_confirmation: PrivacyPreflightConfirmation::new(), idempotency_key: None, created_at: None, @@ -1257,6 +1289,7 @@ pub struct TradeRevisionProposalRequest { pub target_relays: RelayResolutionPolicy, pub publish_mode: PublishMode, pub ack_policy: AckPolicy, + pub evidence_mode: TradeEvidenceMode, pub privacy_confirmation: PrivacyPreflightConfirmation, pub idempotency_key: Option<SdkIdempotencyKey>, pub created_at: Option<RadrootsSdkTimestamp>, @@ -1274,6 +1307,7 @@ impl TradeRevisionProposalRequest { target_relays: RelayResolutionPolicy, publish_mode: PublishMode, ack_policy: AckPolicy, + evidence_mode: TradeEvidenceMode, ) -> Self { Self { actor, @@ -1285,6 +1319,7 @@ impl TradeRevisionProposalRequest { target_relays, publish_mode, ack_policy, + evidence_mode, privacy_confirmation: PrivacyPreflightConfirmation::new(), idempotency_key: None, created_at: None, @@ -1325,6 +1360,7 @@ pub struct TradeRevisionDecisionRequest { pub target_relays: RelayResolutionPolicy, pub publish_mode: PublishMode, pub ack_policy: AckPolicy, + pub evidence_mode: TradeEvidenceMode, pub privacy_confirmation: PrivacyPreflightConfirmation, pub idempotency_key: Option<SdkIdempotencyKey>, pub created_at: Option<RadrootsSdkTimestamp>, @@ -1340,6 +1376,7 @@ impl TradeRevisionDecisionRequest { target_relays: RelayResolutionPolicy, publish_mode: PublishMode, ack_policy: AckPolicy, + evidence_mode: TradeEvidenceMode, ) -> Self { Self { actor, @@ -1349,6 +1386,7 @@ impl TradeRevisionDecisionRequest { target_relays, publish_mode, ack_policy, + evidence_mode, privacy_confirmation: PrivacyPreflightConfirmation::new(), idempotency_key: None, created_at: None, @@ -2937,7 +2975,7 @@ impl<'sdk> TradesClient<'sdk> { } } - #[cfg(feature = "relay-runtime")] + #[cfg(all(feature = "signer-adapters", feature = "relay-runtime"))] pub async fn status_with_fetch_adapter<A>( &self, request: TradeStatusRequest, @@ -3176,12 +3214,14 @@ fn trade_evidence_fetch_request( query_plan: &TradeEvidenceQueryPlan, relay_targets: &[String], ) -> Result<RadrootsRelayFetchRequest, RadrootsSdkError> { - let filters = query_plan + let mut filters = Vec::new(); + for branch in query_plan .branches .iter() .filter(|branch| branch.filter.active) - .map(trade_evidence_branch_filter) - .collect::<Result<Vec<_>, _>>()?; + { + filters.extend(trade_evidence_branch_filters(branch)?); + } if filters.is_empty() { return Err(RadrootsSdkError::InvalidRequest { message: "trade evidence query plan has no active relay filters".to_owned(), @@ -3291,16 +3331,32 @@ fn trade_evidence_branch( } #[cfg(all(feature = "runtime", feature = "relay-runtime"))] +fn trade_evidence_branch_filters( + branch: &TradeEvidenceQueryBranch, +) -> Result<Vec<RadrootsNostrFilter>, RadrootsSdkError> { + if branch.filter.event_kinds.is_empty() { + return Err(RadrootsSdkError::InvalidRequest { + message: "trade evidence branch has no event kinds".to_owned(), + }); + } + branch + .filter + .event_kinds + .iter() + .map(|kind| trade_evidence_branch_filter(branch, *kind)) + .collect() +} + +#[cfg(all(feature = "runtime", feature = "relay-runtime"))] fn trade_evidence_branch_filter( branch: &TradeEvidenceQueryBranch, + kind: u32, ) -> Result<RadrootsNostrFilter, RadrootsSdkError> { let mut filter = RadrootsNostrFilter::new().limit(branch.filter.limit as usize); - for kind in &branch.filter.event_kinds { - let nostr_kind = u16::try_from(*kind).map_err(|_| RadrootsSdkError::InvalidRequest { - message: format!("trade evidence event kind {kind} exceeds Nostr filter range"), - })?; - filter = filter.kind(RadrootsNostrKind::Custom(nostr_kind)); - } + let nostr_kind = u16::try_from(kind).map_err(|_| RadrootsSdkError::InvalidRequest { + message: format!("trade evidence event kind {kind} exceeds Nostr filter range"), + })?; + filter = filter.kind(RadrootsNostrKind::Custom(nostr_kind)); if let Some(author_pubkey) = branch.filter.author_pubkey.as_ref() { let author = author_pubkey .parse::<RadrootsNostrPublicKey>() @@ -4415,11 +4471,13 @@ impl<'sdk> TradeBuyerClient<'sdk> { target_relays, publish_mode, ack_policy, + evidence_mode, privacy_confirmation, idempotency_key, created_at, } = request; - let context = trade_mutation_context(self.sdk, locator, "trade.cancel").await?; + let context = + trade_mutation_context(self.sdk, locator, evidence_mode, "trade.cancel").await?; let cancellation = RadrootsOrderCancellation { order_id: context.order_id.clone(), listing_addr: context.listing_addr.clone(), @@ -4500,6 +4558,7 @@ impl<'sdk> TradeBuyerClient<'sdk> { target_relays, publish_mode, ack_policy, + evidence_mode, privacy_confirmation, idempotency_key, created_at, @@ -4509,7 +4568,9 @@ impl<'sdk> TradeBuyerClient<'sdk> { trade_revision_decision_privacy_fields(&decision), &privacy_confirmation, )?; - let context = trade_mutation_context(self.sdk, locator, "trade.revision_decision").await?; + let context = + trade_mutation_context(self.sdk, locator, evidence_mode, "trade.revision_decision") + .await?; let previous_event_id = context.pending_revision_event_id.clone().ok_or_else(|| { RadrootsSdkError::InvalidRequest { message: "trade revision decision requires a pending revision".to_owned(), @@ -4645,11 +4706,87 @@ impl<'sdk> TradeSellerClient<'sdk> { target_relays, publish_mode, ack_policy, + evidence_mode, + privacy_confirmation, + idempotency_key, + created_at, + } = request; + let context = + trade_mutation_context(self.sdk, locator, evidence_mode, "trade.accept").await?; + let decision = RadrootsOrderDecision { + order_id: context.order_id.clone(), + listing_addr: context.listing_addr.clone(), + buyer_pubkey: context.buyer_pubkey.clone(), + seller_pubkey: context.seller_pubkey.clone(), + decision: RadrootsOrderDecisionOutcome::Accepted { + 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(), + request_event: event_ptr(&context.root_event_id), + decision, + created_at, + })?; + if publish_mode == PublishMode::DryRun { + return Ok(TradeMutationOutcome::DryRun { plan }); + } + let receipt = client + .enqueue_prepared_decision( + &actor, + plan, + target_relays, + publish_mode, + ack_policy, + idempotency_key, + ) + .await?; + trade_product_post_enqueue_outcome( + self.sdk, + publish_mode, + ack_policy, + receipt.outbox_event_id, + receipt, + ) + .await + } + + #[cfg(feature = "relay-runtime")] + pub async fn accept_trade_with_fetch_adapter<A>( + &self, + request: TradeAcceptRequest, + adapter: &A, + ) -> Result<TradeMutationOutcome<TradeDecisionPlan, TradeDecisionReceipt>, RadrootsSdkError> + where + A: RadrootsRelayFetchAdapter, + { + validate_trade_product_publish_policy(request.publish_mode, request.ack_policy)?; + let TradeAcceptRequest { + actor, + locator, + inventory_commitments, + target_relays, + publish_mode, + ack_policy, + evidence_mode, privacy_confirmation, idempotency_key, created_at, } = request; - let context = trade_mutation_context(self.sdk, locator, "trade.accept").await?; + let context = trade_mutation_context_with_fetch_adapter( + self.sdk, + locator, + evidence_mode, + "trade.accept", + adapter, + ) + .await?; let decision = RadrootsOrderDecision { order_id: context.order_id.clone(), listing_addr: context.listing_addr.clone(), @@ -4708,11 +4845,13 @@ impl<'sdk> TradeSellerClient<'sdk> { target_relays, publish_mode, ack_policy, + evidence_mode, privacy_confirmation, idempotency_key, created_at, } = request; - let context = trade_mutation_context(self.sdk, locator, "trade.decline").await?; + let context = + trade_mutation_context(self.sdk, locator, evidence_mode, "trade.decline").await?; let decision = RadrootsOrderDecision { order_id: context.order_id.clone(), listing_addr: context.listing_addr.clone(), @@ -4774,11 +4913,14 @@ impl<'sdk> TradeSellerClient<'sdk> { target_relays, publish_mode, ack_policy, + evidence_mode, privacy_confirmation, idempotency_key, created_at, } = request; - let context = trade_mutation_context(self.sdk, locator, "trade.propose_revision").await?; + let context = + trade_mutation_context(self.sdk, locator, evidence_mode, "trade.propose_revision") + .await?; let proposal = RadrootsOrderRevisionProposal { revision_id, order_id: context.order_id.clone(), @@ -4848,11 +4990,113 @@ struct TradeProductMutationContext { async fn trade_mutation_context( sdk: &crate::RadrootsClient, locator: RadrootsTradeLocator, + evidence_mode: TradeEvidenceMode, operation: &'static str, ) -> Result<TradeProductMutationContext, RadrootsSdkError> { - let status = trades_client(sdk) - .status(TradeStatusRequest::new(locator.clone())) - .await?; + let status = trade_mutation_status(sdk, locator.clone(), &evidence_mode, operation).await?; + trade_mutation_context_from_status(locator, &evidence_mode, operation, status) +} + +#[cfg(feature = "signer-adapters")] +async fn trade_mutation_status( + sdk: &crate::RadrootsClient, + locator: RadrootsTradeLocator, + evidence_mode: &TradeEvidenceMode, + operation: &'static str, +) -> Result<TradeStatusReceipt, RadrootsSdkError> { + match evidence_mode { + TradeEvidenceMode::LocalOnly => { + trades_client(sdk) + .status( + TradeStatusRequest::new(locator).with_source(SdkTradeStatusSource::LocalOnly), + ) + .await + } + TradeEvidenceMode::RequireExplicitEvidence { evidence } => { + ingest_explicit_trade_mutation_evidence(sdk, operation, evidence).await?; + trades_client(sdk) + .status( + TradeStatusRequest::new(locator).with_source(SdkTradeStatusSource::LocalOnly), + ) + .await + } + TradeEvidenceMode::ResyncBeforeMutation => { + #[cfg(feature = "relay-runtime")] + { + let adapter = RadrootsNostrClientFetchAdapter; + let status = trades_client(sdk) + .status_with_fetch_adapter( + TradeStatusRequest::new(locator) + .with_source(SdkTradeStatusSource::ResyncThenLocal), + &adapter, + ) + .await?; + require_trade_mutation_online_evidence_clean(operation, &status)?; + Ok(status) + } + #[cfg(not(feature = "relay-runtime"))] + { + let _ = locator; + Err(RadrootsSdkError::ProductSyncUnsupported { + operation, + required_feature: "relay-runtime", + }) + } + } + } +} + +#[cfg(all(feature = "signer-adapters", feature = "relay-runtime"))] +async fn trade_mutation_context_with_fetch_adapter<A>( + sdk: &crate::RadrootsClient, + locator: RadrootsTradeLocator, + evidence_mode: TradeEvidenceMode, + operation: &'static str, + adapter: &A, +) -> Result<TradeProductMutationContext, RadrootsSdkError> +where + A: RadrootsRelayFetchAdapter, +{ + let status = match evidence_mode { + TradeEvidenceMode::LocalOnly => { + trades_client(sdk) + .status( + TradeStatusRequest::new(locator.clone()) + .with_source(SdkTradeStatusSource::LocalOnly), + ) + .await? + } + TradeEvidenceMode::RequireExplicitEvidence { ref evidence } => { + ingest_explicit_trade_mutation_evidence(sdk, operation, evidence).await?; + trades_client(sdk) + .status( + TradeStatusRequest::new(locator.clone()) + .with_source(SdkTradeStatusSource::LocalOnly), + ) + .await? + } + TradeEvidenceMode::ResyncBeforeMutation => { + let status = trades_client(sdk) + .status_with_fetch_adapter( + TradeStatusRequest::new(locator.clone()) + .with_source(SdkTradeStatusSource::ResyncThenLocal), + adapter, + ) + .await?; + require_trade_mutation_online_evidence_clean(operation, &status)?; + status + } + }; + trade_mutation_context_from_status(locator, &evidence_mode, operation, status) +} + +#[cfg(feature = "signer-adapters")] +fn trade_mutation_context_from_status( + locator: RadrootsTradeLocator, + evidence_mode: &TradeEvidenceMode, + operation: &'static str, + status: TradeStatusReceipt, +) -> Result<TradeProductMutationContext, RadrootsSdkError> { if status.status == TradeStatusKind::Ambiguous { return Err(RadrootsSdkError::TradeAmbiguous { operation: operation.to_owned(), @@ -4865,8 +5109,13 @@ async fn trade_mutation_context( }); } if !status.found { + let evidence_requirement = match evidence_mode { + TradeEvidenceMode::LocalOnly => "locally projected", + TradeEvidenceMode::ResyncBeforeMutation => "resynced", + TradeEvidenceMode::RequireExplicitEvidence { .. } => "explicitly ingested", + }; return Err(RadrootsSdkError::InvalidRequest { - message: format!("{operation} requires a locally projected trade"), + message: format!("{operation} requires a {evidence_requirement} trade"), }); } let root_event_id = @@ -4916,6 +5165,54 @@ async fn trade_mutation_context( } #[cfg(feature = "signer-adapters")] +async fn ingest_explicit_trade_mutation_evidence( + sdk: &crate::RadrootsClient, + operation: &'static str, + evidence: &[TradeEvidenceIngestRequest], +) -> Result<(), RadrootsSdkError> { + if evidence.is_empty() { + return Err(RadrootsSdkError::InvalidRequest { + message: format!("{operation} requires explicit trade evidence"), + }); + } + let client = trades_client(sdk); + for event in evidence { + client.ingest_evidence(event.clone()).await?; + } + Ok(()) +} + +#[cfg(feature = "signer-adapters")] +fn require_trade_mutation_online_evidence_clean( + operation: &'static str, + status: &TradeStatusReceipt, +) -> Result<(), RadrootsSdkError> { + let evidence = + status + .online_evidence + .as_ref() + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: format!("{operation} requires online mutation evidence"), + })?; + if evidence.malformed_count > 0 + || evidence.out_of_filter_count > 0 + || evidence.skipped_over_limit_count > 0 + || evidence.unsupported_count > 0 + { + return Err(RadrootsSdkError::InvalidRequest { + message: format!( + "{operation} refused online mutation evidence: malformed_count={}, out_of_filter_count={}, skipped_over_limit_count={}, unsupported_count={}", + evidence.malformed_count, + evidence.out_of_filter_count, + evidence.skipped_over_limit_count, + evidence.unsupported_count + ), + }); + } + Ok(()) +} + +#[cfg(feature = "signer-adapters")] fn event_ptr(event_id: &RadrootsEventId) -> RadrootsNostrEventPtr { RadrootsNostrEventPtr { id: event_id.as_str().to_owned(), diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -2,8 +2,12 @@ #[cfg(all(feature = "signer-adapters", feature = "local-signer"))] use std::path::Path; +#[cfg(feature = "relay-runtime")] +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +#[cfg(feature = "relay-runtime")] +use futures::future::BoxFuture; use nostr::JsonUtil; use radroots_authority::RadrootsActorContext; use radroots_core::{ @@ -35,18 +39,23 @@ use radroots_nostr::prelude::{ }; use radroots_outbox::RadrootsOutbox; use radroots_relay_transport::{RadrootsMockRelayFetchAdapter, RadrootsRelayFetchItem}; +#[cfg(feature = "relay-runtime")] +use radroots_relay_transport::{ + RadrootsRelayFetchAdapter, RadrootsRelayFetchRequest, RadrootsRelayTransportError, +}; use radroots_sdk::{ AckPolicy, DvmValidationReceiptIngestRequest, PublishMode, RadrootsClient, RadrootsSdkError, RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, RelayResolutionPolicy, SdkMutationState, SdkRelayTargetSet, SdkRelayUrlPolicy, SdkTradeStatusIssue, SdkTradeStatusIssueKind, SdkTradeStatusSource, TRADE_STATUS_DEFAULT_LIMIT, TRADE_STATUS_MAX_LIMIT, TRADE_SUBMIT_OPERATION_KIND, TradeAcceptRequest, TradeCancelRequest, TradeDeclineRequest, - TradeEvidenceIngestRequest, TradeEvidenceQueryBranchKind, TradeMutationOutcome, - TradeProposeRequest, TradeRequestEvidenceIngestRequest, TradeResyncRelayOutcomeKind, - TradeResyncRelayTransportOutcomeKind, TradeResyncRequest, TradeRevisionDecisionRequest, - TradeRevisionProposalRequest, TradeSellerInboxRequest, TradeStatusKind, - TradeStatusNextActionKind, TradeStatusRequest, TradeValidationReceiptInspectRequest, - TradeValidationReceiptListRequest, TradeValidationReceiptVerifyRequest, + TradeEvidenceIngestRequest, TradeEvidenceMode, TradeEvidenceQueryBranchKind, + TradeMutationOutcome, TradeProposeRequest, TradeRequestEvidenceIngestRequest, + TradeResyncRelayOutcomeKind, TradeResyncRelayTransportOutcomeKind, TradeResyncRequest, + TradeRevisionDecisionRequest, TradeRevisionProposalRequest, TradeSellerInboxRequest, + TradeStatusKind, TradeStatusNextActionKind, TradeStatusRequest, + TradeValidationReceiptInspectRequest, TradeValidationReceiptListRequest, + TradeValidationReceiptVerifyRequest, }; use radroots_sdk::{PrivacyPreflightConfirmation, PrivacyPreflightStatus, ProductSensitivityField}; #[cfg(all(feature = "signer-adapters", feature = "local-signer"))] @@ -94,6 +103,40 @@ struct FailingStructSerializer { failure: FailingSerializeFailure, } +#[cfg(feature = "relay-runtime")] +#[derive(Clone, Default)] +struct CapturingRelayFetchAdapter { + filters_json: Arc<Mutex<Vec<String>>>, +} + +#[cfg(feature = "relay-runtime")] +impl CapturingRelayFetchAdapter { + fn filters_json(&self) -> Vec<String> { + self.filters_json + .lock() + .expect("captured filters lock") + .clone() + } +} + +#[cfg(feature = "relay-runtime")] +impl RadrootsRelayFetchAdapter for CapturingRelayFetchAdapter { + fn fetch<'a>( + &'a self, + request: RadrootsRelayFetchRequest, + ) -> BoxFuture<'a, Result<Vec<RadrootsRelayFetchItem>, RadrootsRelayTransportError>> { + Box::pin(async move { + let filters = request + .filters() + .iter() + .map(JsonUtil::as_json) + .collect::<Vec<_>>(); + *self.filters_json.lock().expect("captured filters lock") = filters; + Ok(vec![relay_eose(RELAY)]) + }) + } +} + struct FailingSerializeStruct { field_index: usize, failure: FailingSerializeFailure, @@ -981,6 +1024,7 @@ async fn trade_product_clients_propose_inbox_accept_status_and_resync() { explicit_trade_relays(), PublishMode::EnqueueOnly, AckPolicy::NoWait, + TradeEvidenceMode::LocalOnly, ) .try_with_idempotency_key("trade-product-facade-accept") .expect("accept idempotency"), @@ -1119,6 +1163,7 @@ async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() { explicit_trade_relays(), PublishMode::EnqueueOnly, AckPolicy::NoWait, + TradeEvidenceMode::LocalOnly, ) .try_with_idempotency_key("trade-product-committed-resync-accept") .expect("accept idempotency"), @@ -1193,6 +1238,493 @@ async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() { assert_eq!(buyer_resync.evidence.inserted_count, 2); } +#[cfg(all( + feature = "signer-adapters", + feature = "local-signer", + feature = "relay-runtime" +))] +#[tokio::test] +async fn trade_product_accept_resync_before_mutation_imports_relay_visible_request() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let buyer_storage_root = tempdir.path().join("buyer-sdk"); + let seller_storage_root = tempdir.path().join("seller-sdk"); + let buyer_sdk = directory_sdk_with_signer_and_relays( + buyer_storage_root.as_path(), + BUYER_SECRET_KEY_HEX, + &[RELAY], + ) + .await; + let seller_sdk = directory_sdk_with_signer_and_relays( + seller_storage_root.as_path(), + SELLER_SECRET_KEY_HEX, + &[RELAY], + ) + .await; + let buyer_store = + RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").event_store_path) + .await + .expect("buyer event store"); + let seller_store = + RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").event_store_path) + .await + .expect("seller event store"); + let propose_receipt = expect_enqueued( + buyer_sdk + .trades() + .buyer() + .propose_trade( + TradeProposeRequest::new( + buyer_actor(), + listing_event_ptr(), + order_request("trade-product-resync-before-accept"), + explicit_trade_relays(), + PublishMode::EnqueueOnly, + AckPolicy::NoWait, + ) + .try_with_idempotency_key("trade-product-resync-before-accept-propose") + .expect("propose idempotency"), + ) + .await + .expect("propose trade"), + ); + assert_eq!( + seller_store + .status_summary() + .await + .expect("seller isolated before mutation") + .total_events, + 0 + ); + let adapter = RadrootsMockRelayFetchAdapter::new(vec![ + relay_event_item_from_store(&buyer_store, &propose_receipt.signed_event_id, RELAY, 4_300) + .await, + relay_eose(RELAY), + ]); + let accept_receipt = expect_enqueued( + seller_sdk + .trades() + .seller() + .accept_trade_with_fetch_adapter( + TradeAcceptRequest::new( + seller_actor(), + propose_receipt.locator.clone(), + vec![RadrootsOrderInventoryCommitment { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + explicit_trade_relays(), + PublishMode::EnqueueOnly, + AckPolicy::NoWait, + TradeEvidenceMode::ResyncBeforeMutation, + ) + .try_with_idempotency_key("trade-product-resync-before-accept") + .expect("accept idempotency"), + &adapter, + ) + .await + .expect("accept trade"), + ); + assert_eq!( + accept_receipt.request_event_id, + propose_receipt.signed_event_id + ); + let status = seller_sdk + .trades() + .status(TradeStatusRequest::new(propose_receipt.locator)) + .await + .expect("seller status after accept"); + assert_eq!(status.status, TradeStatusKind::AgreedPendingRhi); + assert_eq!( + status.decision_event_id, + Some(accept_receipt.signed_event_id) + ); + assert_eq!( + seller_store + .status_summary() + .await + .expect("seller imported request and decision") + .total_events, + 2 + ); +} + +#[cfg(all( + feature = "signer-adapters", + feature = "local-signer", + feature = "relay-runtime" +))] +#[tokio::test] +async fn trade_product_revision_status_resync_imports_pending_revision_proposal() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let buyer_storage_root = tempdir.path().join("buyer-sdk"); + let seller_storage_root = tempdir.path().join("seller-sdk"); + let decision_storage_root = tempdir.path().join("decision-sdk"); + let buyer_sdk = directory_sdk_with_signer_and_relays( + buyer_storage_root.as_path(), + BUYER_SECRET_KEY_HEX, + &[RELAY], + ) + .await; + let seller_sdk = directory_sdk_with_signer_and_relays( + seller_storage_root.as_path(), + SELLER_SECRET_KEY_HEX, + &[RELAY], + ) + .await; + let decision_sdk = directory_sdk_with_signer_and_relays( + decision_storage_root.as_path(), + BUYER_SECRET_KEY_HEX, + &[RELAY], + ) + .await; + let buyer_store = + RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").event_store_path) + .await + .expect("buyer event store"); + let seller_store = + RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").event_store_path) + .await + .expect("seller event store"); + let decision_store = RadrootsEventStore::open_file( + &decision_sdk + .storage_paths() + .expect("paths") + .event_store_path, + ) + .await + .expect("decision event store"); + let propose_receipt = expect_enqueued( + buyer_sdk + .trades() + .buyer() + .propose_trade( + TradeProposeRequest::new( + buyer_actor(), + listing_event_ptr(), + order_request("trade-product-resync-before-revision-decision"), + explicit_trade_relays(), + PublishMode::EnqueueOnly, + AckPolicy::NoWait, + ) + .try_with_idempotency_key("trade-product-resync-before-revision-decision-propose") + .expect("propose idempotency"), + ) + .await + .expect("propose trade"), + ); + seller_sdk + .trades() + .resync() + .resync_with_fetch_adapter( + TradeResyncRequest::new(propose_receipt.locator.clone()), + &RadrootsMockRelayFetchAdapter::new(vec![ + relay_event_item_from_store( + &buyer_store, + &propose_receipt.signed_event_id, + RELAY, + 4_400, + ) + .await, + relay_eose(RELAY), + ]), + ) + .await + .expect("seller request resync"); + let revision_id: RadrootsOrderRevisionId = "revision-product-resync-before-decision" + .parse() + .expect("revision id"); + let proposal_receipt = expect_enqueued( + seller_sdk + .trades() + .seller() + .propose_revision( + TradeRevisionProposalRequest::new( + seller_actor(), + propose_receipt.locator.clone(), + revision_id, + vec![RadrootsOrderItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 3, + }], + revision_economics(), + "increase quantity", + explicit_trade_relays(), + PublishMode::EnqueueOnly, + AckPolicy::NoWait, + TradeEvidenceMode::LocalOnly, + ) + .with_privacy_confirmation(public_note_confirmation()) + .try_with_idempotency_key("trade-product-resync-before-revision-proposal") + .expect("revision proposal idempotency"), + ) + .await + .expect("propose revision"), + ); + let adapter = RadrootsMockRelayFetchAdapter::new(vec![ + relay_event_item_from_store(&buyer_store, &propose_receipt.signed_event_id, RELAY, 4_500) + .await, + relay_event_item_from_store( + &seller_store, + &proposal_receipt.signed_event_id, + RELAY, + 4_501, + ) + .await, + relay_eose(RELAY), + ]); + let status = decision_sdk + .trades() + .status_with_fetch_adapter( + TradeStatusRequest::new(propose_receipt.locator.clone()) + .with_source(SdkTradeStatusSource::ResyncThenLocal), + &adapter, + ) + .await + .expect("decision resync status"); + + assert_eq!(status.status, TradeStatusKind::RevisionProposed); + assert_eq!( + status.pending_revision_event_id, + Some(proposal_receipt.signed_event_id) + ); + assert!(status.eligibility.can_decide_revision); + assert_eq!( + decision_store + .status_summary() + .await + .expect("decision imported request and proposal") + .total_events, + 2 + ); +} + +#[cfg(all( + feature = "signer-adapters", + feature = "local-signer", + feature = "relay-runtime" +))] +#[tokio::test] +async fn trade_product_accept_local_only_does_not_fetch_relay_evidence() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let buyer_storage_root = tempdir.path().join("buyer-sdk"); + let seller_storage_root = tempdir.path().join("seller-sdk"); + let buyer_sdk = directory_sdk_with_signer_and_relays( + buyer_storage_root.as_path(), + BUYER_SECRET_KEY_HEX, + &[RELAY], + ) + .await; + let seller_sdk = directory_sdk_with_signer_and_relays( + seller_storage_root.as_path(), + SELLER_SECRET_KEY_HEX, + &[RELAY], + ) + .await; + let seller_store = + RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").event_store_path) + .await + .expect("seller event store"); + let propose_receipt = expect_enqueued( + buyer_sdk + .trades() + .buyer() + .propose_trade( + TradeProposeRequest::new( + buyer_actor(), + listing_event_ptr(), + order_request("trade-product-local-only-no-fetch"), + explicit_trade_relays(), + PublishMode::EnqueueOnly, + AckPolicy::NoWait, + ) + .try_with_idempotency_key("trade-product-local-only-no-fetch-propose") + .expect("propose idempotency"), + ) + .await + .expect("propose trade"), + ); + let adapter = RadrootsMockRelayFetchAdapter::new(vec![relay_closed(RELAY, "must not fetch")]); + let error = seller_sdk + .trades() + .seller() + .accept_trade_with_fetch_adapter( + TradeAcceptRequest::new( + seller_actor(), + propose_receipt.locator, + vec![RadrootsOrderInventoryCommitment { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + explicit_trade_relays(), + PublishMode::EnqueueOnly, + AckPolicy::NoWait, + TradeEvidenceMode::LocalOnly, + ), + &adapter, + ) + .await + .expect_err("local-only accept without local evidence"); + let RadrootsSdkError::InvalidRequest { message } = error else { + panic!("expected invalid request"); + }; + assert!(message.contains("trade.accept requires a locally projected trade")); + assert_eq!( + seller_store + .status_summary() + .await + .expect("seller remains empty") + .total_events, + 0 + ); +} + +#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] +#[tokio::test] +async fn trade_product_accept_require_explicit_evidence_ingests_supplied_request() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let buyer_storage_root = tempdir.path().join("buyer-sdk"); + let seller_storage_root = tempdir.path().join("seller-sdk"); + let buyer_sdk = + directory_sdk_with_signer(buyer_storage_root.as_path(), BUYER_SECRET_KEY_HEX).await; + let seller_sdk = + directory_sdk_with_signer(seller_storage_root.as_path(), SELLER_SECRET_KEY_HEX).await; + let buyer_store = + RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").event_store_path) + .await + .expect("buyer event store"); + let seller_store = + RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").event_store_path) + .await + .expect("seller event store"); + let propose_receipt = expect_enqueued( + buyer_sdk + .trades() + .buyer() + .propose_trade( + TradeProposeRequest::new( + buyer_actor(), + listing_event_ptr(), + order_request("trade-product-explicit-accept"), + explicit_trade_relays(), + PublishMode::EnqueueOnly, + AckPolicy::NoWait, + ) + .try_with_idempotency_key("trade-product-explicit-accept-propose") + .expect("propose idempotency"), + ) + .await + .expect("propose trade"), + ); + let request_event = event_from_store(&buyer_store, &propose_receipt.signed_event_id).await; + let accept_receipt = expect_enqueued( + seller_sdk + .trades() + .seller() + .accept_trade( + TradeAcceptRequest::new( + seller_actor(), + propose_receipt.locator.clone(), + vec![RadrootsOrderInventoryCommitment { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + explicit_trade_relays(), + PublishMode::EnqueueOnly, + AckPolicy::NoWait, + TradeEvidenceMode::require_explicit_evidence([ + TradeEvidenceIngestRequest::new(request_event), + ]), + ) + .try_with_idempotency_key("trade-product-explicit-accept") + .expect("accept idempotency"), + ) + .await + .expect("accept trade"), + ); + assert_eq!( + accept_receipt.request_event_id, + propose_receipt.signed_event_id + ); + let status = seller_sdk + .trades() + .status(TradeStatusRequest::new(propose_receipt.locator)) + .await + .expect("seller explicit status after accept"); + assert_eq!(status.status, TradeStatusKind::AgreedPendingRhi); + assert_eq!( + seller_store + .status_summary() + .await + .expect("seller imported request and decision") + .total_events, + 2 + ); +} + +#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] +#[tokio::test] +async fn trade_product_accept_require_explicit_evidence_rejects_empty_evidence() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let buyer_storage_root = tempdir.path().join("buyer-sdk"); + let seller_storage_root = tempdir.path().join("seller-sdk"); + let buyer_sdk = + directory_sdk_with_signer(buyer_storage_root.as_path(), BUYER_SECRET_KEY_HEX).await; + let seller_sdk = + directory_sdk_with_signer(seller_storage_root.as_path(), SELLER_SECRET_KEY_HEX).await; + let seller_store = + RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").event_store_path) + .await + .expect("seller event store"); + let propose_receipt = expect_enqueued( + buyer_sdk + .trades() + .buyer() + .propose_trade( + TradeProposeRequest::new( + buyer_actor(), + listing_event_ptr(), + order_request("trade-product-empty-explicit-accept"), + explicit_trade_relays(), + PublishMode::EnqueueOnly, + AckPolicy::NoWait, + ) + .try_with_idempotency_key("trade-product-empty-explicit-accept-propose") + .expect("propose idempotency"), + ) + .await + .expect("propose trade"), + ); + let error = seller_sdk + .trades() + .seller() + .accept_trade(TradeAcceptRequest::new( + seller_actor(), + propose_receipt.locator, + vec![RadrootsOrderInventoryCommitment { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + explicit_trade_relays(), + PublishMode::EnqueueOnly, + AckPolicy::NoWait, + TradeEvidenceMode::require_explicit_evidence(Vec::<TradeEvidenceIngestRequest>::new()), + )) + .await + .expect_err("empty explicit evidence"); + let RadrootsSdkError::InvalidRequest { message } = error else { + panic!("expected invalid request"); + }; + assert_eq!(message, "trade.accept requires explicit trade evidence"); + assert_eq!( + seller_store + .status_summary() + .await + .expect("seller remains empty") + .total_events, + 0 + ); +} + #[cfg(feature = "relay-runtime")] #[tokio::test] async fn trade_validation_receipts_fetch_from_relays_and_select_worker_evidence() { @@ -1776,6 +2308,39 @@ async fn trade_resync_duplicate_replay_is_idempotent() { #[cfg(feature = "relay-runtime")] #[tokio::test] +async fn trade_resync_splits_lifecycle_branch_into_single_kind_filters() { + let (_tempdir, sdk, _store) = directory_sdk_and_store_with_relays(&[RELAY]).await; + let adapter = CapturingRelayFetchAdapter::default(); + sdk.trades() + .resync() + .resync_with_fetch_adapter( + TradeResyncRequest::new(RadrootsTradeLocator::from_order_id(order_id( + "resync-lifecycle-filter-shape", + ))), + &adapter, + ) + .await + .expect("resync"); + + let filters = adapter.filters_json(); + assert_eq!(filters.len(), 6); + for kind in [3422, 3423, 3424, 3425, 3432, 3440] { + assert!( + filters + .iter() + .any(|filter| filter.contains(format!("\"kinds\":[{kind}]").as_str())), + "missing single-kind filter for {kind}: {filters:?}" + ); + } + assert!( + !filters + .iter() + .any(|filter| filter.contains("\"kinds\":[3423,3424,3425,3432]")) + ); +} + +#[cfg(feature = "relay-runtime")] +#[tokio::test] async fn trade_resync_reports_malformed_evidence_without_poisoning_store() { let (_tempdir, sdk, store) = directory_sdk_and_store_with_relays(&[RELAY]).await; let adapter = @@ -1921,6 +2486,20 @@ async fn relay_event_item_from_store( } } +async fn event_from_store( + source: &RadrootsEventStore, + event_id: &RadrootsEventId, +) -> RadrootsNostrEvent { + let stored = source + .get_event(event_id.as_str()) + .await + .expect("source event lookup") + .expect("source event"); + let event = + serde_json::from_str::<nostr::Event>(stored.raw_json.as_str()).expect("stored raw event"); + radroots_event_from_nostr(&event) +} + fn relay_raw_event_item( event: &nostr::Event, relay_url: &str, @@ -2102,6 +2681,7 @@ async fn trade_product_decline_requires_public_reason_privacy_confirmation() { explicit_trade_relays(), PublishMode::EnqueueOnly, AckPolicy::NoWait, + TradeEvidenceMode::LocalOnly, )) .await .expect_err("missing public note confirmation"); @@ -2150,6 +2730,7 @@ async fn trade_product_decline_requires_public_reason_privacy_confirmation() { explicit_trade_relays(), PublishMode::EnqueueOnly, AckPolicy::NoWait, + TradeEvidenceMode::LocalOnly, ) .with_privacy_confirmation(public_note_confirmation()) .try_with_idempotency_key("trade-product-privacy-decline-confirmed") @@ -2207,6 +2788,7 @@ async fn trade_product_cancel_blocks_sensitive_fulfillment_reason_before_mutatio explicit_trade_relays(), PublishMode::EnqueueOnly, AckPolicy::NoWait, + TradeEvidenceMode::LocalOnly, ) .with_privacy_confirmation(public_note_confirmation()), ) @@ -2276,6 +2858,7 @@ async fn trade_product_cancel_enqueues_with_locator_and_updates_status() { explicit_trade_relays(), PublishMode::EnqueueOnly, AckPolicy::NoWait, + TradeEvidenceMode::LocalOnly, ) .with_privacy_confirmation(public_note_confirmation()) .try_with_idempotency_key("trade-product-cancel") @@ -2350,6 +2933,7 @@ async fn trade_product_revision_lifecycle_uses_locator_and_updates_status() { explicit_trade_relays(), PublishMode::EnqueueOnly, AckPolicy::NoWait, + TradeEvidenceMode::LocalOnly, ) .with_privacy_confirmation(public_note_confirmation()) .try_with_idempotency_key("trade-product-revision-proposal") @@ -2383,6 +2967,7 @@ async fn trade_product_revision_lifecycle_uses_locator_and_updates_status() { explicit_trade_relays(), PublishMode::EnqueueOnly, AckPolicy::NoWait, + TradeEvidenceMode::LocalOnly, ) .try_with_idempotency_key("trade-product-revision-decision") .expect("revision decision idempotency"), @@ -5711,6 +6296,7 @@ async fn trade_product_mutation_returns_structured_ambiguity() { explicit_trade_relays(), PublishMode::EnqueueOnly, AckPolicy::NoWait, + TradeEvidenceMode::LocalOnly, )) .await .expect_err("ambiguous product mutation"); diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -174,6 +174,7 @@ const REQUIRED_TRADE_SIGNER_EXPORTS: &[&str] = &[ "TradeDecisionPlan", "TradeDecisionReceipt", "TradeDeclineRequest", + "TradeEvidenceMode", "TradeMutationOutcome", "TradeProposeRequest", "TradeRevisionDecisionPlan", @@ -797,6 +798,56 @@ fn order_runtime_rejects_retired_status_source_names() { } #[test] +fn order_runtime_mutation_requests_require_evidence_mode() { + let source = read_source( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/orders_runtime.rs") + .as_path(), + ); + + assert!( + source.contains("pub enum TradeEvidenceMode"), + "src/orders_runtime.rs must define explicit trade mutation evidence modes" + ); + for variant in [ + "LocalOnly", + "ResyncBeforeMutation", + "RequireExplicitEvidence", + ] { + assert!( + source.contains(variant), + "TradeEvidenceMode must retain `{variant}`" + ); + } + for request in [ + "TradeAcceptRequest", + "TradeDeclineRequest", + "TradeCancelRequest", + "TradeRevisionProposalRequest", + "TradeRevisionDecisionRequest", + ] { + let struct_block = struct_block(source.as_str(), request); + assert!( + struct_block.contains("pub evidence_mode: TradeEvidenceMode"), + "{request} must carry explicit evidence_mode" + ); + } + for constructor in [ + "impl TradeAcceptRequest", + "impl TradeDeclineRequest", + "impl TradeCancelRequest", + "impl TradeRevisionProposalRequest", + "impl TradeRevisionDecisionRequest", + ] { + let impl_source = impl_block(source.as_str(), constructor); + assert!( + impl_source.contains("evidence_mode: TradeEvidenceMode"), + "{constructor}::new must require explicit evidence mode" + ); + } +} + +#[test] fn dvm_runtime_public_exports_are_explicit() { let source = read_source( Path::new(env!("CARGO_MANIFEST_DIR")) @@ -1350,6 +1401,18 @@ fn impl_block<'source>(source: &'source str, marker: &str) -> &'source str { &source_after_start[..end] } +fn struct_block<'source>(source: &'source str, marker: &str) -> &'source str { + let signature = format!("pub struct {marker}"); + let start = source + .find(signature.as_str()) + .unwrap_or_else(|| panic!("failed to find struct block marker `{marker}`")); + let source_after_start = &source[start..]; + let end = source_after_start + .find("\n}\n\n#[cfg(") + .unwrap_or(source_after_start.len()); + &source_after_start[..end] +} + fn is_rust_identifier_character(character: char) -> bool { character == '_' || character.is_ascii_alphanumeric() }