app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 2b4fd9b5e013c96586eab39fe8db1256ea578603
parent 47d7a18d4a424f2a090c59aa19c527b2ed7b6300
Author: triesap <tyson@radroots.org>
Date:   Sat,  4 Jul 2026 00:11:29 +0000

runtime: route trade mutations through SDK evidence modes

- remove caller-owned trade evidence vectors from app SDK requests
- use resync-before-mutation for routine trade lifecycle calls
- seed relay-visible evidence in mutation runtime tests
- guard production sources against raw evidence bypasses

Diffstat:
Mcrates/desktop/src/runtime.rs | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mcrates/desktop/src/source_guards.rs | 36++++++++++++++++++++++++++++++++++++
Mcrates/runtime/src/sdk.rs | 38+++++++-------------------------------
3 files changed, 120 insertions(+), 68 deletions(-)

diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -247,7 +247,6 @@ struct ResolvedAppOrderRevisionDecisionEvidence { #[derive(Clone, Debug, Eq, PartialEq)] struct ResolvedAppOrderLifecycleEvidence { - evidence_events: Vec<SdkRadrootsNostrEvent>, request_event_id: String, status: RadrootsTradeWorkflowState, agreement_event_id: Option<String>, @@ -4830,7 +4829,6 @@ impl DesktopAppRuntimeState { source_kind: AppSdkWorkflowReceiptSourceKind, source_record_id: &str, ) -> Result<(), AppSqliteError> { - let evidence_events = self.sdk_trade_request_evidence_events(payload.app_order_id)?; let operation_kind = TRADE_DECISION_OPERATION_KIND; let actor_pubkey = self .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderDecision( @@ -4842,7 +4840,6 @@ impl DesktopAppRuntimeState { actor_account_id: payload.context.account_id.clone(), actor_pubkey: actor_pubkey.clone(), signer_keys: identity.into_keys(), - evidence_events: evidence_events.clone(), locator: trade_locator_from_decision_payload(payload)?, decision: trade_decision_from_publish_payload(payload)?, idempotency_key: Some(sdk_idempotency_key(source_record_id)), @@ -4875,7 +4872,6 @@ impl DesktopAppRuntimeState { source_kind: AppSdkWorkflowReceiptSourceKind, source_record_id: &str, ) -> Result<(), AppSqliteError> { - let evidence_events = self.sdk_trade_lifecycle_evidence_events(payload.app_order_id)?; let operation_kind = TRADE_REVISION_PROPOSAL_OPERATION_KIND; let actor_pubkey = self .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderRevisionProposal( @@ -4887,7 +4883,6 @@ impl DesktopAppRuntimeState { actor_account_id: payload.context.account_id.clone(), actor_pubkey: actor_pubkey.clone(), signer_keys: identity.into_keys(), - evidence_events: evidence_events.clone(), locator: trade_locator_from_revision_proposal_payload(payload)?, revision_id: publish_revision_id(payload.revision_id.as_str())?, items: payload.items.clone(), @@ -4923,7 +4918,6 @@ impl DesktopAppRuntimeState { source_kind: AppSdkWorkflowReceiptSourceKind, source_record_id: &str, ) -> Result<(), AppSqliteError> { - let evidence_events = self.sdk_trade_lifecycle_evidence_events(payload.app_order_id)?; let operation_kind = TRADE_REVISION_DECISION_OPERATION_KIND; let actor_pubkey = self .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderRevisionDecision( @@ -4935,7 +4929,6 @@ impl DesktopAppRuntimeState { actor_account_id: payload.context.account_id.clone(), actor_pubkey: actor_pubkey.clone(), signer_keys: identity.into_keys(), - evidence_events: evidence_events.clone(), locator: trade_locator_from_revision_decision_payload(payload)?, revision_id: publish_revision_id(payload.revision_id.as_str())?, decision: payload.decision.clone(), @@ -4969,7 +4962,6 @@ impl DesktopAppRuntimeState { source_kind: AppSdkWorkflowReceiptSourceKind, source_record_id: &str, ) -> Result<(), AppSqliteError> { - let evidence_events = self.sdk_trade_lifecycle_evidence_events(payload.app_order_id)?; let operation_kind = TRADE_CANCELLATION_OPERATION_KIND; let actor_pubkey = self .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderCancellation( @@ -4981,7 +4973,6 @@ impl DesktopAppRuntimeState { actor_account_id: payload.context.account_id.clone(), actor_pubkey: actor_pubkey.clone(), signer_keys: identity.into_keys(), - evidence_events: evidence_events.clone(), locator: trade_locator_from_cancellation_payload(payload)?, reason: payload.reason.clone(), idempotency_key: Some(sdk_idempotency_key(source_record_id)), @@ -5578,7 +5569,6 @@ impl DesktopAppRuntimeState { .then_with(|| left.id.cmp(&right.id)) }); - let mut evidence_events = vec![request.request_event.clone()]; let mut buckets = AppActiveOrderEvidenceBuckets::default(); let request_event_id = active_order_event_id(request.request_event_id.as_str(), "request_event_id")?; @@ -5597,7 +5587,6 @@ impl DesktopAppRuntimeState { { continue; } - evidence_events.push(event.clone()); let event_id = active_order_event_id(event.id.as_str(), "event_id")?; let author_pubkey = active_order_pubkey(event.author.as_str(), "author_pubkey")?; match event.kind { @@ -5713,7 +5702,6 @@ impl DesktopAppRuntimeState { }) .transpose()?; Ok(ResolvedAppOrderLifecycleEvidence { - evidence_events, request_event_id: request.request_event_id.clone(), status: projection.status, agreement_event_id: projection @@ -5745,23 +5733,6 @@ impl DesktopAppRuntimeState { }) } - fn sdk_trade_request_evidence_events( - &self, - order_id: OrderId, - ) -> Result<Vec<SdkRadrootsNostrEvent>, AppSqliteError> { - let request = self.resolve_seller_order_request_evidence(order_id)?; - Ok(vec![request.request_event]) - } - - fn sdk_trade_lifecycle_evidence_events( - &self, - order_id: OrderId, - ) -> Result<Vec<SdkRadrootsNostrEvent>, AppSqliteError> { - let request = self.resolve_seller_order_request_evidence(order_id)?; - let lifecycle = self.resolve_order_lifecycle_evidence(&request)?; - Ok(lifecycle.evidence_events) - } - fn collect_order_lifecycle_signed_events( &self, ) -> Result<Vec<SdkRadrootsNostrEvent>, AppSqliteError> { @@ -9990,6 +9961,10 @@ mod tests { fn event_count(&self) -> usize { self.events.lock().expect("relay events lock").len() } + + fn seed_event(&self, event: serde_json::Value) { + self.events.lock().expect("relay events lock").push(event); + } } fn relay_event_matches_filters( @@ -10102,6 +10077,47 @@ mod tests { }); } + fn seed_relay_with_sdk_event(relay: &ThreadedAckRelay, event: &SdkRadrootsNostrEvent) { + relay.seed_event(json!({ + "id": event.id, + "pubkey": event.author, + "created_at": event.created_at, + "kind": event.kind, + "tags": event.tags, + "content": event.content, + "sig": event.sig, + })); + } + + fn seed_relay_with_order_request( + runtime: &DesktopAppRuntime, + relay: &ThreadedAckRelay, + order_id: OrderId, + ) { + let request_event = runtime + .lock_state() + .resolve_seller_order_request_evidence(order_id) + .expect("order request evidence should resolve") + .request_event; + seed_relay_with_sdk_event(relay, &request_event); + } + + fn seed_relay_with_shared_event_id( + relay: &ThreadedAckRelay, + paths: &AppDesktopRuntimePaths, + event_id: &str, + ) { + let event = shared_local_event_records(paths) + .into_iter() + .find(|record| record.event_id.as_deref() == Some(event_id)) + .and_then(|record| { + signed_event_from_local_record(&record) + .expect("shared signed event record should decode") + }) + .expect("shared signed event should exist"); + seed_relay_with_sdk_event(relay, &event); + } + impl Drop for ThreadedAckRelay { fn drop(&mut self) { if let Some(shutdown_tx) = self.shutdown_tx.take() { @@ -15476,6 +15492,8 @@ mod tests { let (runtime, paths, order_id, _product_id, seller_pubkey, _buyer_pubkey) = seller_order_decision_sdk_runtime("seller_order_accept_publish", 6, 2); install_direct_relay_sync_transport(&runtime, &relay); + restore_sdk_runtime_with_relays(&runtime, &paths, vec![relay.url().to_owned()]); + seed_relay_with_order_request(&runtime, &relay, order_id); assert!( runtime @@ -15484,7 +15502,7 @@ mod tests { ); assert_eq!(persisted_order_status(&runtime, order_id), "needs_action"); - assert_eq!(relay.event_count(), 0); + assert_eq!(relay.event_count(), 1); assert!(!shared_local_event_records(&paths).iter().any(|record| { record.family == LocalRecordFamily::SignedEvent && record.event_kind == Some(3423) @@ -15505,6 +15523,8 @@ mod tests { let (runtime, paths, order_id, _product_id, seller_pubkey, _buyer_pubkey) = seller_order_decision_sdk_runtime("seller_order_decline_publish", 6, 2); install_direct_relay_sync_transport(&runtime, &relay); + restore_sdk_runtime_with_relays(&runtime, &paths, vec![relay.url().to_owned()]); + seed_relay_with_order_request(&runtime, &relay, order_id); assert!( runtime @@ -15513,7 +15533,7 @@ mod tests { ); assert_eq!(persisted_order_status(&runtime, order_id), "needs_action"); - assert_eq!(relay.event_count(), 0); + assert_eq!(relay.event_count(), 1); assert!(!shared_local_event_records(&paths).iter().any(|record| { record.family == LocalRecordFamily::SignedEvent && record.event_kind == Some(3423) @@ -16151,6 +16171,11 @@ mod tests { let relay = ThreadedAckRelay::spawn(); let fixture = linked_buyer_request_runtime("linked_buyer_order_cancel"); install_direct_relay_sync_transport(&fixture.runtime, &relay); + restore_sdk_runtime_with_relays( + &fixture.runtime, + &fixture.paths, + vec![relay.url().to_owned()], + ); fixture .runtime .refresh_shared_local_events() @@ -16161,6 +16186,7 @@ mod tests { .open_personal_order_detail(fixture.order_id) .expect("linked buyer order detail should open") ); + seed_relay_with_shared_event_id(&relay, &fixture.paths, fixture.request_event_id.as_str()); assert!( fixture @@ -16173,7 +16199,7 @@ mod tests { persisted_order_status(&fixture.runtime, fixture.order_id), "needs_action" ); - assert_eq!(relay.event_count(), 0); + assert_eq!(relay.event_count(), 1); let cancellation_events = shared_order_events_by_kind(&fixture.paths, 3432, fixture.buyer_pubkey.as_str()); assert!(cancellation_events.is_empty()); @@ -16314,7 +16340,7 @@ mod tests { let relay = ThreadedAckRelay::spawn(); let fixture = linked_buyer_request_runtime("linked_buyer_order_revision"); let proposal_key = "linked-buyer-order-revision-proposal"; - let _proposal_event_id = append_signed_order_revision_proposal_record_with_prev( + let proposal_event_id = append_signed_order_revision_proposal_record_with_prev( &fixture.paths, fixture.trade_order_id.as_str(), proposal_key, @@ -16326,6 +16352,11 @@ mod tests { ); let revision_id = format!("revision-{proposal_key}"); install_direct_relay_sync_transport(&fixture.runtime, &relay); + restore_sdk_runtime_with_relays( + &fixture.runtime, + &fixture.paths, + vec![relay.url().to_owned()], + ); fixture .runtime .refresh_shared_local_events() @@ -16336,6 +16367,8 @@ mod tests { .open_personal_order_detail(fixture.order_id) .expect("linked buyer order detail should open") ); + seed_relay_with_shared_event_id(&relay, &fixture.paths, fixture.request_event_id.as_str()); + seed_relay_with_shared_event_id(&relay, &fixture.paths, proposal_event_id.as_str()); assert!( fixture @@ -16344,7 +16377,7 @@ mod tests { .expect("linked buyer revision decision should publish") ); - assert_eq!(relay.event_count(), 0); + assert_eq!(relay.event_count(), 2); let revision_decision_events = shared_order_events_by_kind(&fixture.paths, 3425, fixture.buyer_pubkey.as_str()); assert!(revision_decision_events.is_empty()); @@ -19351,9 +19384,16 @@ mod tests { } fn restore_sdk_runtime(runtime: &DesktopAppRuntime, paths: &AppDesktopRuntimePaths) { - let sdk_runtime = - super::start_desktop_sdk_runtime(paths, vec!["ws://127.0.0.1:8080".to_owned()]) - .expect("sdk runtime should restart"); + restore_sdk_runtime_with_relays(runtime, paths, vec!["ws://127.0.0.1:8080".to_owned()]); + } + + fn restore_sdk_runtime_with_relays( + runtime: &DesktopAppRuntime, + paths: &AppDesktopRuntimePaths, + relay_urls: Vec<String>, + ) { + let sdk_runtime = super::start_desktop_sdk_runtime(paths, relay_urls) + .expect("sdk runtime should restart"); { let mut handle = runtime.sdk_runtime.lock().expect("sdk runtime lock"); *handle = Some(sdk_runtime); diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -1150,6 +1150,18 @@ const STRICT_SDK_BOUNDARY_FORBIDDEN_PATTERNS: &[SdkBoundaryForbiddenPattern] = & reason: "app production sources must use AppSdkRuntime DVM methods instead of removed SDK validation handles", }, SdkBoundaryForbiddenPattern { + pattern: "TradeEvidenceIngestRequest", + reason: "app production sources must use SDK mutation evidence modes instead of raw trade evidence ingestion", + }, + SdkBoundaryForbiddenPattern { + pattern: ".ingest_evidence(", + reason: "app production sources must use SDK mutation evidence modes instead of raw trade evidence ingestion", + }, + SdkBoundaryForbiddenPattern { + pattern: "accept_trade_with_fetch_adapter", + reason: "app production sources must not inject relay fetch adapters around SDK mutation workflows", + }, + SdkBoundaryForbiddenPattern { pattern: "SdkTransportMode::RelayDirect", reason: "app production sources must not configure direct relay publish transport", }, @@ -1640,6 +1652,30 @@ fn strict_sdk_boundary_scanner_rejects_unexcepted_new_production_paths() { .iter() .any(|finding| finding.pattern == "status_client(") ); + let evidence_findings = unexcepted_sdk_boundary_patterns( + "crates/runtime/src/sdk.rs", + "fn mutate() { sdk.trades().ingest_evidence(TradeEvidenceIngestRequest::new(event)); }", + ); + assert_eq!(evidence_findings.len(), 2); + assert!( + evidence_findings + .iter() + .any(|finding| finding.pattern == "TradeEvidenceIngestRequest") + ); + assert!( + evidence_findings + .iter() + .any(|finding| finding.pattern == ".ingest_evidence(") + ); + let adapter_hook_findings = unexcepted_sdk_boundary_patterns( + "crates/runtime/src/sdk.rs", + "fn mutate() { sdk.trades().seller().accept_trade_with_fetch_adapter(request, adapter); }", + ); + assert_eq!(adapter_hook_findings.len(), 1); + assert_eq!( + adapter_hook_findings[0].pattern, + "accept_trade_with_fetch_adapter" + ); let default_term_findings = unexcepted_sdk_boundary_patterns( "crates/desktop/src/runtime.rs", "fn project() { let fallback_profile = profile; }", diff --git a/crates/runtime/src/sdk.rs b/crates/runtime/src/sdk.rs @@ -12,7 +12,7 @@ use std::{ use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner}; use radroots_events::{ - RadrootsNostrEvent, RadrootsNostrEventPtr, + RadrootsNostrEventPtr, contract::RadrootsActorRole, farm::RadrootsFarm, ids::{RadrootsAddressableCoordinate, RadrootsOrderRevisionId}, @@ -36,7 +36,7 @@ use radroots_sdk::{ TRADE_DECISION_OPERATION_KIND, TRADE_REVISION_DECISION_OPERATION_KIND, TRADE_REVISION_PROPOSAL_OPERATION_KIND, TRADE_SUBMIT_OPERATION_KIND, TradeAcceptRequest, TradeCancelRequest, TradeCancellationPlan, TradeCancellationReceipt, TradeDecisionPlan, - TradeDecisionReceipt, TradeDeclineRequest, TradeEvidenceIngestRequest, TradeMutationOutcome, + TradeDecisionReceipt, TradeDeclineRequest, TradeEvidenceMode, TradeMutationOutcome, TradeProposeRequest, TradeRevisionDecisionPlan, TradeRevisionDecisionReceipt, TradeRevisionDecisionRequest, TradeRevisionProposalPlan, TradeRevisionProposalReceipt, TradeRevisionProposalRequest, TradeSubmitPlan, TradeSubmitReceipt, @@ -266,7 +266,6 @@ pub struct AppSdkTradeDecisionRequest { pub actor_account_id: String, pub actor_pubkey: String, pub signer_keys: RadrootsNostrKeys, - pub evidence_events: Vec<RadrootsNostrEvent>, pub locator: RadrootsTradeLocator, pub decision: AppSdkTradeDecision, pub idempotency_key: Option<String>, @@ -276,7 +275,6 @@ pub struct AppSdkTradeRevisionProposalRequest { pub actor_account_id: String, pub actor_pubkey: String, pub signer_keys: RadrootsNostrKeys, - pub evidence_events: Vec<RadrootsNostrEvent>, pub locator: RadrootsTradeLocator, pub revision_id: RadrootsOrderRevisionId, pub items: Vec<RadrootsOrderItem>, @@ -289,7 +287,6 @@ pub struct AppSdkTradeRevisionDecisionRequest { pub actor_account_id: String, pub actor_pubkey: String, pub signer_keys: RadrootsNostrKeys, - pub evidence_events: Vec<RadrootsNostrEvent>, pub locator: RadrootsTradeLocator, pub revision_id: RadrootsOrderRevisionId, pub decision: RadrootsOrderRevisionOutcome, @@ -300,7 +297,6 @@ pub struct AppSdkTradeCancellationRequest { pub actor_account_id: String, pub actor_pubkey: String, pub signer_keys: RadrootsNostrKeys, - pub evidence_events: Vec<RadrootsNostrEvent>, pub locator: RadrootsTradeLocator, pub reason: String, pub idempotency_key: Option<String>, @@ -1567,14 +1563,12 @@ fn trade_decision_with_sdk( config: &AppSdkConfig, request: AppSdkTradeDecisionRequest, ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> { - let evidence_events = request.evidence_events.clone(); let actor = sdk_actor_context( request.actor_pubkey.as_str(), request.actor_account_id.as_str(), RadrootsActorRole::Seller, )?; let sdk = runtime.block_on(build_sdk_runtime_with_signer(config, request.signer_keys))?; - ingest_trade_evidence_events(runtime, &sdk, evidence_events)?; let publish_mode = app_trade_publish_mode(); let ack_policy = app_trade_ack_policy(); let outcome = match request.decision { @@ -1588,6 +1582,7 @@ fn trade_decision_with_sdk( app_trade_relay_resolution_policy(), publish_mode, ack_policy, + TradeEvidenceMode::ResyncBeforeMutation, ) .with_privacy_confirmation(app_trade_privacy_confirmation()); if let Some(idempotency_key) = request.idempotency_key.as_deref() { @@ -1607,6 +1602,7 @@ fn trade_decision_with_sdk( app_trade_relay_resolution_policy(), publish_mode, ack_policy, + TradeEvidenceMode::ResyncBeforeMutation, ) .with_privacy_confirmation(app_trade_privacy_confirmation()); if let Some(idempotency_key) = request.idempotency_key.as_deref() { @@ -1627,14 +1623,12 @@ fn trade_revision_propose_with_sdk( config: &AppSdkConfig, request: AppSdkTradeRevisionProposalRequest, ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> { - let evidence_events = request.evidence_events.clone(); let actor = sdk_actor_context( request.actor_pubkey.as_str(), request.actor_account_id.as_str(), RadrootsActorRole::Seller, )?; let sdk = runtime.block_on(build_sdk_runtime_with_signer(config, request.signer_keys))?; - ingest_trade_evidence_events(runtime, &sdk, evidence_events)?; let mut sdk_request = TradeRevisionProposalRequest::new( actor, request.locator, @@ -1645,6 +1639,7 @@ fn trade_revision_propose_with_sdk( app_trade_relay_resolution_policy(), app_trade_publish_mode(), app_trade_ack_policy(), + TradeEvidenceMode::ResyncBeforeMutation, ) .with_privacy_confirmation(app_trade_privacy_confirmation()); if let Some(idempotency_key) = request.idempotency_key.as_deref() { @@ -1663,14 +1658,12 @@ fn trade_revision_decide_with_sdk( config: &AppSdkConfig, request: AppSdkTradeRevisionDecisionRequest, ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> { - let evidence_events = request.evidence_events.clone(); let actor = sdk_actor_context( request.actor_pubkey.as_str(), request.actor_account_id.as_str(), RadrootsActorRole::Buyer, )?; let sdk = runtime.block_on(build_sdk_runtime_with_signer(config, request.signer_keys))?; - ingest_trade_evidence_events(runtime, &sdk, evidence_events)?; let mut sdk_request = TradeRevisionDecisionRequest::new( actor, request.locator, @@ -1679,6 +1672,7 @@ fn trade_revision_decide_with_sdk( app_trade_relay_resolution_policy(), app_trade_publish_mode(), app_trade_ack_policy(), + TradeEvidenceMode::ResyncBeforeMutation, ) .with_privacy_confirmation(app_trade_privacy_confirmation()); if let Some(idempotency_key) = request.idempotency_key.as_deref() { @@ -1702,14 +1696,12 @@ fn trade_cancel_with_sdk( config: &AppSdkConfig, request: AppSdkTradeCancellationRequest, ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> { - let evidence_events = request.evidence_events.clone(); let actor = sdk_actor_context( request.actor_pubkey.as_str(), request.actor_account_id.as_str(), RadrootsActorRole::Buyer, )?; let sdk = runtime.block_on(build_sdk_runtime_with_signer(config, request.signer_keys))?; - ingest_trade_evidence_events(runtime, &sdk, evidence_events)?; let mut sdk_request = TradeCancelRequest::new( actor, request.locator, @@ -1717,6 +1709,7 @@ fn trade_cancel_with_sdk( app_trade_relay_resolution_policy(), app_trade_publish_mode(), app_trade_ack_policy(), + TradeEvidenceMode::ResyncBeforeMutation, ) .with_privacy_confirmation(app_trade_privacy_confirmation()); if let Some(idempotency_key) = request.idempotency_key.as_deref() { @@ -1730,23 +1723,6 @@ fn trade_cancel_with_sdk( app_sdk_trade_cancellation_receipt(outcome, request.actor_pubkey) } -fn ingest_trade_evidence_events( - runtime: &tokio::runtime::Runtime, - sdk: &RadrootsClient, - evidence_events: Vec<RadrootsNostrEvent>, -) -> Result<(), AppSdkRuntimeIssue> { - runtime - .block_on(async { - for event in evidence_events { - sdk.trades() - .ingest_evidence(TradeEvidenceIngestRequest::new(event)) - .await?; - } - Ok::<(), RadrootsSdkError>(()) - }) - .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error)) -} - fn sdk_actor_context( actor_pubkey: &str, actor_account_id: &str,