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:
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()
}