commit 5c695d9197f36945772e05c820085671b61a24a4
parent 6d526c6eed09458c366ede7d3897185f160784e0
Author: triesap <tyson@radroots.org>
Date: Thu, 18 Jun 2026 22:58:52 -0700
app: route order decisions through sdk
- add an AppSdkRuntime command for seller order decisions
- record SDK migration receipts for migrated decision records
- remove direct relay publishing from migrated decision payloads
- tighten sync metadata and source guards around decision work
Diffstat:
5 files changed, 443 insertions(+), 139 deletions(-)
diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs
@@ -9,10 +9,11 @@ use chrono::{DateTime, Duration, Utc};
use radroots_app_core::{
AppBuildIdentity, AppDesktopRuntimePaths, AppRuntimeCapture, AppRuntimeMode,
AppRuntimePathsError, AppRuntimeSnapshot, AppSdkConfig, AppSdkDiagnostics,
- AppSdkFarmPublishRequest, AppSdkLifecycleState, AppSdkOrderSubmitRequest,
- AppSdkProjectionLifecycleState, AppSdkRelayUrlPolicy, AppSdkRuntime, AppSdkRuntimeError,
- AppSdkRuntimeIssue, AppSdkRuntimeStatus, AppSdkStoragePaths, AppSdkWorkflowReceipt,
- AppSharedAccountsPaths, PackDayExportWriteError, prepare_pack_day_export_bundle_at_data_root,
+ AppSdkFarmPublishRequest, AppSdkLifecycleState, AppSdkOrderDecisionRequest,
+ AppSdkOrderSubmitRequest, AppSdkProjectionLifecycleState, AppSdkRelayUrlPolicy, AppSdkRuntime,
+ AppSdkRuntimeError, AppSdkRuntimeIssue, AppSdkRuntimeStatus, AppSdkStoragePaths,
+ AppSdkWorkflowReceipt, AppSharedAccountsPaths, PackDayExportWriteError,
+ prepare_pack_day_export_bundle_at_data_root,
shared_local_events_database_path_from_shared_accounts, write_prepared_pack_day_export_bundle,
};
use radroots_app_remote_signer::{
@@ -116,9 +117,9 @@ use radroots_sdk::protocol::order::{
RadrootsOrderRevisionProposal,
};
use radroots_sdk::{
- FARM_PUBLISH_OPERATION_KIND, ORDER_SUBMIT_OPERATION_KIND, RadrootsSdkClient, RadrootsSdkConfig,
- RelayConfig, SdkEnvironment, SdkPublishReceipt, SdkTransportMode, SdkTransportReceipt,
- SignerConfig,
+ FARM_PUBLISH_OPERATION_KIND, ORDER_DECISION_OPERATION_KIND, ORDER_SUBMIT_OPERATION_KIND,
+ RadrootsSdkClient, RadrootsSdkConfig, RelayConfig, SdkEnvironment, SdkPublishReceipt,
+ SdkTransportMode, SdkTransportReceipt, SignerConfig,
};
use radroots_sql_core::SqliteExecutor;
use radroots_trade::listing::parse_public_listing_address;
@@ -224,6 +225,7 @@ pub enum AppSellerOrderDecisionCommand {
#[derive(Clone, Debug, Eq, PartialEq)]
struct ResolvedAppSellerOrderRequest {
+ request_event: SdkRadrootsNostrEvent,
request_event_id: String,
request_author_pubkey: String,
listing_event_id: Option<String>,
@@ -2816,15 +2818,14 @@ impl DesktopAppRuntimeState {
command: AppSellerOrderDecisionCommand,
) -> Result<bool, AppSqliteError> {
let payload = self.prepare_seller_order_decision(order_id, command)?;
- let operation = PendingSyncOperation::from_publish_payload(
- AppPublishPayload::OrderDecision(payload),
- current_utc_timestamp(),
- )
- .map_err(|_| AppSqliteError::InvalidProjection {
- reason: "seller order decision publish payload must serialize",
- })?;
- let _ = self.enqueue_selected_account_sync_operation_once(operation)?;
- self.attempt_sync(SyncTrigger::ManualRefresh)
+ let source_record_id = order_decision_sdk_source_record_id(&payload);
+ self.enqueue_order_decision_payload_via_sdk(
+ &payload,
+ AppSdkMigrationReceiptSourceKind::LocalOutbox,
+ source_record_id.as_str(),
+ )?;
+ let _ = self.refresh_selected_account_sync()?;
+ Ok(true)
}
fn prepare_seller_order_fulfillment(
@@ -5332,6 +5333,57 @@ impl DesktopAppRuntimeState {
}
}
+ fn enqueue_order_decision_payload_via_sdk(
+ &self,
+ payload: &AppOrderDecisionPublishPayload,
+ source_kind: AppSdkMigrationReceiptSourceKind,
+ source_record_id: &str,
+ ) -> Result<(), AppSqliteError> {
+ let operation_kind = ORDER_DECISION_OPERATION_KIND;
+ let request_evidence = self.resolve_seller_order_request_evidence(payload.app_order_id)?;
+ let actor_pubkey = self
+ .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderDecision(
+ payload.clone(),
+ ))
+ .and_then(|identity| {
+ let actor_pubkey = identity.public_key_hex();
+ let target_relays = normalized_app_sync_relay_urls(&self.nostr_relay_urls)?;
+ let request = AppSdkOrderDecisionRequest {
+ actor_account_id: payload.context.account_id.clone(),
+ actor_pubkey: actor_pubkey.clone(),
+ signer_keys: identity.into_keys(),
+ request_event: request_evidence.request_event,
+ request_event_ptr: order_decision_sdk_request_event_ptr(
+ payload,
+ target_relays.as_slice(),
+ )?,
+ decision: order_decision_publish_payload_to_sdk_decision(payload)?,
+ relay_url_policy: sdk_relay_url_policy_for_targets(target_relays.as_slice()),
+ target_relays,
+ idempotency_key: Some(sdk_idempotency_key(source_record_id)),
+ };
+ self.enqueue_app_sdk_order_decision(request)
+ .map(|receipt| (actor_pubkey, receipt))
+ .map_err(sync_transport_error_from_sdk_runtime_error)
+ });
+ match actor_pubkey {
+ Ok((actor_pubkey, receipt)) => self.record_app_sdk_migration_success(
+ source_kind,
+ source_record_id,
+ operation_kind,
+ actor_pubkey.as_str(),
+ &receipt,
+ ),
+ Err(error) => self.record_app_sdk_migration_failure(
+ source_kind,
+ source_record_id,
+ operation_kind,
+ None,
+ sync_transport_error_detail_json(&error),
+ ),
+ }
+ }
+
fn local_signing_identity_for_publish_payload(
&self,
payload: &AppPublishPayload,
@@ -5356,6 +5408,13 @@ impl DesktopAppRuntimeState {
self.with_app_sdk_runtime(|runtime| runtime.enqueue_order_submit(request))
}
+ fn enqueue_app_sdk_order_decision(
+ &self,
+ request: AppSdkOrderDecisionRequest,
+ ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> {
+ self.with_app_sdk_runtime(|runtime| runtime.enqueue_order_decision(request))
+ }
+
fn with_app_sdk_runtime<T>(
&self,
command: impl FnOnce(&AppSdkRuntime) -> Result<T, AppSdkRuntimeError>,
@@ -7624,6 +7683,10 @@ fn farm_publish_source_record(
})
}
+fn order_decision_sdk_source_record_id(payload: &AppOrderDecisionPublishPayload) -> String {
+ format!("app:order_decision:{}", payload.app_order_id)
+}
+
fn sdk_relay_url_policy_for_targets(target_relays: &[String]) -> AppSdkRelayUrlPolicy {
if target_relays
.iter()
@@ -7788,20 +7851,9 @@ async fn publish_app_payload(
AppPublishPayload::OrderRequest(_) => Err(AppSyncTransportError::failed(
"order request publish uses AppSdkRuntime",
)),
- AppPublishPayload::OrderDecision(payload) => {
- let decision = order_decision_publish_payload_to_sdk_decision(payload)?;
- let request_event_id = publish_event_id(payload.request_event_id.as_str())?;
- client
- .order()
- .publish_order_decision_with_identity(
- identity,
- &request_event_id,
- &request_event_id,
- &decision,
- )
- .await
- .map_err(|error| AppSyncTransportError::failed(error.to_string()))
- }
+ AppPublishPayload::OrderDecision(_) => Err(AppSyncTransportError::failed(
+ "order decision publish uses AppSdkRuntime",
+ )),
AppPublishPayload::OrderRevisionProposal(payload) => {
let proposal = order_revision_proposal_publish_payload_to_sdk_revision(payload)?;
let request_event_id = publish_event_id(payload.request_event_id.as_str())?;
@@ -8111,6 +8163,22 @@ fn order_request_sdk_target_relays(
Ok(selected_relays)
}
+fn order_decision_sdk_request_event_ptr(
+ payload: &AppOrderDecisionPublishPayload,
+ target_relays: &[String],
+) -> Result<RadrootsNostrEventPtr, AppSyncTransportError> {
+ let request_event_id = payload.request_event_id.trim();
+ if request_event_id.is_empty() {
+ return Err(AppSyncTransportError::failed(
+ "order decision publish requires request event id",
+ ));
+ }
+ Ok(RadrootsNostrEventPtr {
+ id: request_event_id.to_owned(),
+ relays: target_relays.first().cloned(),
+ })
+}
+
#[cfg(test)]
fn selected_listing_relay(
listing_relays: &[String],
@@ -10165,6 +10233,7 @@ fn insert_seller_order_request_evidence(
matched_requests
.entry(event.id.clone())
.or_insert_with(|| ResolvedAppSellerOrderRequest {
+ request_event: event.clone(),
request_event_id: event.id.clone(),
request_author_pubkey: event.author.clone(),
listing_event_id: listing_event_id_from_tags(&event.tags),
@@ -10485,13 +10554,15 @@ mod tests {
LocalEventsStore, LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus,
RelayDeliveryEvidence, SourceRuntime,
};
- use radroots_nostr::prelude::radroots_nostr_build_event;
+ use radroots_nostr::prelude::{
+ RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp,
+ radroots_event_from_nostr, radroots_nostr_build_event,
+ };
use radroots_nostr_accounts::prelude::{
RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore,
RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory, RadrootsSecretVault,
account_secret_slot,
};
- use radroots_sdk::ORDER_SUBMIT_OPERATION_KIND;
use radroots_sdk::protocol::events::{
RadrootsNostrEvent as SdkRadrootsNostrEvent, RadrootsNostrEventPtr,
};
@@ -10504,6 +10575,7 @@ mod tests {
RadrootsOrderRevisionOutcome, RadrootsOrderRevisionProposal,
RadrootsOrderSettlementDecision, RadrootsOrderSettlementOutcome,
};
+ use radroots_sdk::{ORDER_DECISION_OPERATION_KIND, ORDER_SUBMIT_OPERATION_KIND};
use radroots_sql_core::{SqlExecutor, SqliteExecutor};
use radroots_trade::order::radroots_order_economics_digest;
use serde_json::json;
@@ -10514,6 +10586,11 @@ mod tests {
use crate::accounts::DesktopLocalIdentityImportRequest;
+ const SDK_TEST_BUYER_SECRET_KEY_HEX: &str =
+ "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
+ const SDK_TEST_BUYER_PUBLIC_KEY_HEX: &str =
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
+
use super::{
APP_DATABASE_FILE_NAME, DesktopAppRuntime, DesktopAppRuntimeActivityContextError,
DesktopAppRuntimeCommandError, DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeState,
@@ -11064,7 +11141,7 @@ mod tests {
}
#[test]
- fn runtime_direct_relay_transport_publishes_typed_order_decision_work() {
+ fn runtime_direct_relay_transport_rejects_typed_order_decision_work() {
let relay = ThreadedAckRelay::spawn();
let manager = RadrootsNostrAccountsManager::new_in_memory();
let account_id = manager
@@ -11097,24 +11174,20 @@ mod tests {
let mut transport =
SdkDirectRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]);
- let result = transport
+ let error = transport
.sync(AppSyncRequest {
trigger: SyncTrigger::ManualRefresh,
checkpoint: SyncCheckpointStatus::never_synced(),
pending_operations: vec![operation],
known_conflicts: Vec::new(),
})
- .expect("direct relay order decision publish should succeed");
+ .expect_err("direct relay order decision publish should use AppSdkRuntime");
- assert_eq!(result.run_status, AppSyncRunStatus::Succeeded);
- assert_eq!(result.pushed_operation_count, 1);
- assert_eq!(result.published_receipts.len(), 1);
- assert_eq!(result.published_receipts[0].event_kind, 3423);
- assert_eq!(
- result.published_receipts[0].event_pubkey,
- identity.public_key_hex()
+ assert_migrated_payload_uses_sdk_runtime(
+ error,
+ "order decision publish uses AppSdkRuntime",
);
- assert_eq!(relay.event_count(), 1);
+ assert_eq!(relay.event_count(), 0);
}
#[test]
@@ -15908,10 +15981,10 @@ mod tests {
}
#[test]
- fn runtime_publishes_seller_order_accept_and_projects_signed_evidence() {
+ fn runtime_enqueues_seller_order_accept_via_sdk() {
let relay = ThreadedAckRelay::spawn();
let (runtime, paths, order_id, _product_id, seller_pubkey, _buyer_pubkey) =
- seller_order_decision_runtime("seller_order_accept_publish", 6, 2);
+ seller_order_decision_sdk_runtime("seller_order_accept_publish", 6, 2);
install_direct_relay_sync_transport(&runtime, &relay);
assert!(
@@ -15920,41 +15993,27 @@ mod tests {
.expect("seller order accept should publish")
);
- assert_eq!(persisted_order_status(&runtime, order_id), "scheduled");
- assert_eq!(relay.event_count(), 1);
- assert!(shared_local_event_records(&paths).iter().any(|record| {
+ assert_eq!(persisted_order_status(&runtime, order_id), "needs_action");
+ assert_eq!(relay.event_count(), 0);
+ assert!(!shared_local_event_records(&paths).iter().any(|record| {
record.family == LocalRecordFamily::SignedEvent
&& record.event_kind == Some(3423)
&& record.event_pubkey.as_deref() == Some(seller_pubkey.as_str())
}));
- let decision_event = shared_seller_order_decision_event(&paths, seller_pubkey.as_str());
- let envelope = radroots_sdk::protocol::order::parse_order_decision(&decision_event)
- .expect("app seller order accept should parse as canonical order decision");
- assert_eq!(envelope.payload.order_id, "seller-order-decision-1");
- assert!(matches!(
- envelope.payload.decision,
- RadrootsOrderDecisionOutcome::Accepted { .. }
- ));
- let request_event_id = signed_order_request_event_id("seller-order-decision-1");
- assert!(event_has_tag(
- &decision_event,
- "e_root",
- request_event_id.as_str()
- ));
- assert!(event_has_tag(
- &decision_event,
- "e_prev",
- request_event_id.as_str()
- ));
+ assert_order_decision_sdk_migration_receipt(
+ &runtime,
+ order_id,
+ AppSdkMigrationState::Enqueued,
+ );
cleanup_bootstrapped_runtime_paths(&paths);
}
#[test]
- fn runtime_publishes_seller_order_decline_and_projects_signed_evidence() {
+ fn runtime_enqueues_seller_order_decline_via_sdk() {
let relay = ThreadedAckRelay::spawn();
let (runtime, paths, order_id, _product_id, seller_pubkey, _buyer_pubkey) =
- seller_order_decision_runtime("seller_order_decline_publish", 6, 2);
+ seller_order_decision_sdk_runtime("seller_order_decline_publish", 6, 2);
install_direct_relay_sync_transport(&runtime, &relay);
assert!(
@@ -15963,31 +16022,18 @@ mod tests {
.expect("seller order decline should publish")
);
- assert_eq!(persisted_order_status(&runtime, order_id), "declined");
- assert_eq!(relay.event_count(), 1);
- assert!(shared_local_event_records(&paths).iter().any(|record| {
+ assert_eq!(persisted_order_status(&runtime, order_id), "needs_action");
+ assert_eq!(relay.event_count(), 0);
+ assert!(!shared_local_event_records(&paths).iter().any(|record| {
record.family == LocalRecordFamily::SignedEvent
&& record.event_kind == Some(3423)
&& record.event_pubkey.as_deref() == Some(seller_pubkey.as_str())
}));
- let decision_event = shared_seller_order_decision_event(&paths, seller_pubkey.as_str());
- let envelope = radroots_sdk::protocol::order::parse_order_decision(&decision_event)
- .expect("app seller order decline should parse as canonical order decision");
- let RadrootsOrderDecisionOutcome::Declined { reason } = envelope.payload.decision else {
- panic!("expected declined decision");
- };
- assert_eq!(reason, "not available");
- let request_event_id = signed_order_request_event_id("seller-order-decision-1");
- assert!(event_has_tag(
- &decision_event,
- "e_root",
- request_event_id.as_str()
- ));
- assert!(event_has_tag(
- &decision_event,
- "e_prev",
- request_event_id.as_str()
- ));
+ assert_order_decision_sdk_migration_receipt(
+ &runtime,
+ order_id,
+ AppSdkMigrationState::Enqueued,
+ );
cleanup_bootstrapped_runtime_paths(&paths);
}
@@ -21526,6 +21572,67 @@ mod tests {
)
}
+ fn seller_order_decision_sdk_runtime(
+ label: &str,
+ stock_count: u32,
+ order_quantity: u32,
+ ) -> (
+ DesktopAppRuntime,
+ AppDesktopRuntimePaths,
+ OrderId,
+ ProductId,
+ String,
+ String,
+ ) {
+ let (runtime, paths) = bootstrapped_runtime(label);
+ let (account_id, farm_id) = provision_ready_farmer_account(&runtime);
+ runtime.lock_state_mut().nostr_relay_urls = vec!["wss://relay.example".to_owned()];
+ let seller_pubkey = runtime
+ .lock_state()
+ .accounts_manager
+ .as_ref()
+ .expect("accounts manager")
+ .resolve_account_selector(account_id.as_str())
+ .expect("selected seller account should resolve")
+ .public_identity
+ .public_key_hex;
+ let buyer_pubkey = SDK_TEST_BUYER_PUBLIC_KEY_HEX.to_owned();
+ let product_id = ProductId::new();
+ let trade_order_id = "seller-order-decision-1";
+ let order_id = projected_order_id_from_trade_request(trade_order_id, buyer_pubkey.as_str());
+ let farm_key = super::d_tag_from_uuid(farm_id.as_uuid());
+ let listing_key = super::d_tag_from_uuid(product_id.as_uuid());
+ let listing_addr = format!("30402:{seller_pubkey}:{listing_key}");
+ let listing_event_id = signed_listing_event_id("seller-order-decision");
+ append_app_signed_listing_record(
+ &paths,
+ account_id.as_str(),
+ seller_pubkey.as_str(),
+ farm_key.as_str(),
+ listing_key.as_str(),
+ listing_event_id.as_str(),
+ stock_count,
+ );
+ append_verified_signed_order_request_record(
+ &paths,
+ trade_order_id,
+ listing_addr.as_str(),
+ listing_event_id.as_str(),
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ order_quantity,
+ );
+
+ (
+ runtime,
+ paths,
+ order_id,
+ product_id,
+ seller_pubkey,
+ buyer_pubkey,
+ )
+ }
+
fn publish_prior_relay_seller_order_accept(
runtime: &DesktopAppRuntime,
relay: &ThreadedAckRelay,
@@ -21778,6 +21885,95 @@ mod tests {
.expect("append signed order request");
}
+ fn append_verified_signed_order_request_record(
+ paths: &AppDesktopRuntimePaths,
+ trade_order_id: &str,
+ listing_addr: &str,
+ listing_event_id: &str,
+ buyer_pubkey: &str,
+ seller_pubkey: &str,
+ order_quantity: u32,
+ ) {
+ assert_eq!(buyer_pubkey, SDK_TEST_BUYER_PUBLIC_KEY_HEX);
+ let database_path = paths
+ .shared_local_events_database_path()
+ .expect("shared local events path");
+ if let Some(parent) = database_path.parent() {
+ fs::create_dir_all(parent).expect("shared local events directory should create");
+ }
+ let executor =
+ SqliteExecutor::open(database_path.as_path()).expect("open shared local events db");
+ let store = LocalEventsStore::new(executor);
+ store.migrate_up().expect("migrate shared local events");
+ let order = RadrootsOrderRequest {
+ order_id: test_order_id(trade_order_id),
+ listing_addr: test_listing_addr(listing_addr),
+ buyer_pubkey: test_pubkey(buyer_pubkey),
+ seller_pubkey: test_pubkey(seller_pubkey),
+ items: vec![RadrootsOrderItem {
+ bin_id: test_bin_id("seller-order-primary-bin"),
+ bin_count: order_quantity,
+ }],
+ economics: signed_order_request_economics(trade_order_id, order_quantity),
+ };
+ let parts = radroots_sdk::protocol::order::build_order_request_draft(
+ &RadrootsNostrEventPtr {
+ id: test_event_id_seed(listing_event_id),
+ relays: Some("wss://relay.example".to_owned()),
+ },
+ &order,
+ )
+ .expect("order request draft should build")
+ .into_wire_parts();
+ let secret_key = RadrootsNostrSecretKey::from_hex(SDK_TEST_BUYER_SECRET_KEY_HEX)
+ .expect("SDK test buyer secret key should parse");
+ let keys = RadrootsNostrKeys::new(secret_key);
+ let signed_event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags)
+ .expect("order request event should build")
+ .custom_created_at(RadrootsNostrTimestamp::from_secs(1_774_000_010))
+ .sign_with_keys(&keys)
+ .expect("order request event should sign");
+ let event = radroots_event_from_nostr(&signed_event);
+ let record_id = format!("app:signed_event:order-request:{trade_order_id}");
+ let relay_delivery_json = RelayDeliveryEvidence::acknowledged(
+ ["wss://relay.example"],
+ ["wss://relay.example"],
+ ["wss://relay.example"],
+ Vec::new(),
+ )
+ .expect("acknowledged relay delivery evidence")
+ .to_json_value()
+ .expect("acknowledged relay delivery json");
+ store
+ .append_record(&LocalEventRecordInput {
+ record_id,
+ family: LocalRecordFamily::SignedEvent,
+ status: LocalRecordStatus::Published,
+ source_runtime: SourceRuntime::Test,
+ created_at_ms: 1_774_000_010_000,
+ inserted_at_ms: 1_774_000_010_001,
+ owner_account_id: None,
+ owner_pubkey: Some(event.author.clone()),
+ farm_id: None,
+ listing_addr: Some(listing_addr.to_owned()),
+ local_work_json: None,
+ event_id: Some(event.id.clone()),
+ event_kind: Some(i64::from(event.kind)),
+ event_pubkey: Some(event.author.clone()),
+ event_created_at: Some(i64::from(event.created_at)),
+ event_tags_json: Some(json!(event.tags.clone())),
+ event_content: Some(event.content.clone()),
+ event_sig: Some(event.sig.clone()),
+ raw_event_json: Some(
+ serde_json::to_value(&event).expect("SDK test event should serialize"),
+ ),
+ outbox_status: PublishOutboxStatus::Acknowledged,
+ relay_set_fingerprint: Some("relay-set".to_owned()),
+ relay_delivery_json: Some(relay_delivery_json),
+ })
+ .expect("append verified signed order request");
+ }
+
fn signed_order_request_economics(
trade_order_id: &str,
order_quantity: u32,
@@ -22698,23 +22894,6 @@ mod tests {
.expect("shared local records should list")
}
- fn shared_seller_order_decision_event(
- paths: &AppDesktopRuntimePaths,
- seller_pubkey: &str,
- ) -> SdkRadrootsNostrEvent {
- let record = shared_local_event_records(paths)
- .into_iter()
- .find(|record| {
- record.family == LocalRecordFamily::SignedEvent
- && record.event_kind == Some(3423)
- && record.event_pubkey.as_deref() == Some(seller_pubkey)
- })
- .expect("shared seller order decision record should exist");
- signed_event_from_local_record(&record)
- .expect("shared seller order decision record should decode")
- .expect("shared seller order decision record should contain signed event")
- }
-
fn shared_order_events_by_kind(
paths: &AppDesktopRuntimePaths,
kind: i64,
@@ -22839,6 +23018,38 @@ mod tests {
}
}
+ fn assert_order_decision_sdk_migration_receipt(
+ runtime: &DesktopAppRuntime,
+ order_id: OrderId,
+ expected_state: AppSdkMigrationState,
+ ) {
+ let source_record_id = format!("app:order_decision:{order_id}");
+ let receipt = runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .sdk_migration_receipt_repository()
+ .load_receipt(
+ AppSdkMigrationReceiptSourceKind::LocalOutbox,
+ source_record_id.as_str(),
+ )
+ .expect("SDK migration receipt should load")
+ .expect("SDK migration receipt should exist");
+ assert_eq!(receipt.source_record_id, source_record_id);
+ assert_eq!(receipt.sdk_operation_kind, ORDER_DECISION_OPERATION_KIND);
+ assert_eq!(
+ receipt.migration_state, expected_state,
+ "receipt detail: {}",
+ receipt.detail_json
+ );
+ if expected_state == AppSdkMigrationState::Enqueued {
+ assert!(receipt.expected_event_id.is_some());
+ assert!(receipt.actor_pubkey.as_deref().is_some_and(is_hex_64));
+ assert!(!receipt.sdk_outbox_event_ids.is_empty());
+ }
+ }
+
fn buyer_order_local_work_record_ids(paths: &AppDesktopRuntimePaths) -> Vec<String> {
shared_local_event_records(paths)
.into_iter()
diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs
@@ -1358,13 +1358,6 @@ const LEGACY_SDK_BOUNDARY_ALLOWLIST: &[LegacySdkBoundaryAllowlistEntry] = &[
},
LegacySdkBoundaryAllowlistEntry {
path: "crates/desktop/src/runtime.rs",
- pattern: "publish_order_decision_with_identity",
- owner: "rpv1-app-sdk-refactor.07",
- reason: "desktop runtime still calls legacy direct SDK order decision publish APIs",
- removal_condition: "remove when seller order decision publish workflow enqueues through AppSdkRuntime",
- },
- LegacySdkBoundaryAllowlistEntry {
- path: "crates/desktop/src/runtime.rs",
pattern: "publish_order_revision_proposal_with_identity",
owner: "rpv1-app-sdk-refactor.07",
reason: "desktop runtime still calls legacy direct SDK order revision proposal publish APIs",
@@ -1442,13 +1435,6 @@ const LEGACY_SDK_BOUNDARY_ALLOWLIST: &[LegacySdkBoundaryAllowlistEntry] = &[
},
LegacySdkBoundaryAllowlistEntry {
path: "crates/sync/src/publish.rs",
- pattern: "publish_order_decision_with_identity",
- owner: "rpv1-app-sdk-refactor.07",
- reason: "sync payload metadata still names legacy order decision SDK publish operations",
- removal_condition: "remove when seller order decision publish payload metadata is replaced by SDK canonical outbox requests",
- },
- LegacySdkBoundaryAllowlistEntry {
- path: "crates/sync/src/publish.rs",
pattern: "publish_order_revision_proposal_with_identity",
owner: "rpv1-app-sdk-refactor.07",
reason: "sync payload metadata still names legacy order revision proposal SDK publish operations",
diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs
@@ -38,12 +38,13 @@ pub use runtime::{
pub use sdk::{
APP_SDK_DEFAULT_COMMAND_QUEUE_CAPACITY, APP_SDK_STORAGE_DIR_NAME, AppSdkConfig,
AppSdkDiagnostics, AppSdkEventStoreDiagnostics, AppSdkFarmPublishRequest,
- AppSdkIntegrityDiagnostics, AppSdkLifecycleState, AppSdkOrderSubmitRequest,
- AppSdkOutboxDiagnostics, AppSdkProjectionLifecycleState, AppSdkProjectionLifecycleStatus,
- AppSdkRelayUrlPolicy, AppSdkRestorePreflightReceipt, AppSdkRestorePreflightRequest,
- AppSdkRuntime, AppSdkRuntimeError, AppSdkRuntimeIssue, AppSdkRuntimeStatus,
- AppSdkSqliteStoreDiagnostics, AppSdkStorageDiagnostics, AppSdkStoragePaths,
- AppSdkSyncDiagnostics, AppSdkSyncEventStoreDiagnostics, AppSdkSyncOutboxDiagnostics,
- AppSdkSyncRelayTargetDiagnostics, AppSdkWorkflowReceipt, app_sdk_storage_root_from_data_root,
+ AppSdkIntegrityDiagnostics, AppSdkLifecycleState, AppSdkOrderDecisionRequest,
+ AppSdkOrderSubmitRequest, AppSdkOutboxDiagnostics, AppSdkProjectionLifecycleState,
+ AppSdkProjectionLifecycleStatus, AppSdkRelayUrlPolicy, AppSdkRestorePreflightReceipt,
+ AppSdkRestorePreflightRequest, AppSdkRuntime, AppSdkRuntimeError, AppSdkRuntimeIssue,
+ AppSdkRuntimeStatus, AppSdkSqliteStoreDiagnostics, AppSdkStorageDiagnostics,
+ AppSdkStoragePaths, AppSdkSyncDiagnostics, AppSdkSyncEventStoreDiagnostics,
+ AppSdkSyncOutboxDiagnostics, AppSdkSyncRelayTargetDiagnostics, AppSdkWorkflowReceipt,
+ app_sdk_storage_root_from_data_root,
};
pub use startup::{AppStartupEvent, AppStartupEventMetadata, launch_startup_event};
diff --git a/crates/runtime/src/sdk.rs b/crates/runtime/src/sdk.rs
@@ -12,16 +12,20 @@ use std::{
use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner};
use radroots_events::{
- RadrootsNostrEventPtr, contract::RadrootsActorRole, farm::RadrootsFarm,
- order::RadrootsOrderRequest,
+ RadrootsNostrEvent, RadrootsNostrEventPtr,
+ contract::RadrootsActorRole,
+ farm::RadrootsFarm,
+ order::{RadrootsOrderDecision, RadrootsOrderRequest},
};
use radroots_nostr::prelude::RadrootsNostrKeys;
use radroots_sdk::{
FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmEnqueueReceipt, IntegrityReceipt,
- IntegrityRequest, ORDER_SUBMIT_OPERATION_KIND, OrderSubmitEnqueueRequest, OrderSubmitReceipt,
- RadrootsSdk, RadrootsSdkError, RadrootsSdkStoragePaths, RestoreReceipt, RestoreRequest,
- SdkBackupVerification, SdkRelayUrlPolicy as SdkRuntimeRelayUrlPolicy, StorageStatusReceipt,
- StorageStatusRequest, SyncStatusReceipt, SyncStatusRequest,
+ IntegrityRequest, ORDER_DECISION_OPERATION_KIND, ORDER_SUBMIT_OPERATION_KIND,
+ OrderDecisionEnqueueRequest, OrderDecisionReceipt, OrderRequestEvidenceIngestRequest,
+ OrderSubmitEnqueueRequest, OrderSubmitReceipt, RadrootsSdk, RadrootsSdkError,
+ RadrootsSdkStoragePaths, RestoreReceipt, RestoreRequest, SdkBackupVerification,
+ SdkRelayUrlPolicy as SdkRuntimeRelayUrlPolicy, StorageStatusReceipt, StorageStatusRequest,
+ SyncStatusReceipt, SyncStatusRequest,
};
use radroots_sdk::{SdkMutationState, SdkRelayTargetPolicy};
use serde::Serialize;
@@ -211,6 +215,18 @@ pub struct AppSdkOrderSubmitRequest {
pub idempotency_key: Option<String>,
}
+pub struct AppSdkOrderDecisionRequest {
+ pub actor_account_id: String,
+ pub actor_pubkey: String,
+ pub signer_keys: RadrootsNostrKeys,
+ pub request_event: RadrootsNostrEvent,
+ pub request_event_ptr: RadrootsNostrEventPtr,
+ pub decision: RadrootsOrderDecision,
+ pub target_relays: Vec<String>,
+ pub relay_url_policy: AppSdkRelayUrlPolicy,
+ pub idempotency_key: Option<String>,
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppSdkWorkflowReceipt {
pub operation_kind: String,
@@ -311,6 +327,10 @@ enum AppSdkWorkerCommand {
AppSdkOrderSubmitRequest,
mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>,
),
+ EnqueueOrderDecision(
+ AppSdkOrderDecisionRequest,
+ mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>,
+ ),
BeginProjectionRebuild(
mpsc::Sender<Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeIssue>>,
),
@@ -329,6 +349,7 @@ impl fmt::Debug for AppSdkWorkerCommand {
Self::RestorePreflight(_, _) => formatter.write_str("RestorePreflight"),
Self::EnqueueFarmPublish(_, _) => formatter.write_str("EnqueueFarmPublish"),
Self::EnqueueOrderSubmit(_, _) => formatter.write_str("EnqueueOrderSubmit"),
+ Self::EnqueueOrderDecision(_, _) => formatter.write_str("EnqueueOrderDecision"),
Self::BeginProjectionRebuild(_) => formatter.write_str("BeginProjectionRebuild"),
Self::CompleteProjectionRebuild(_) => formatter.write_str("CompleteProjectionRebuild"),
}
@@ -468,6 +489,15 @@ impl AppSdkRuntime {
})
}
+ pub fn enqueue_order_decision(
+ &self,
+ request: AppSdkOrderDecisionRequest,
+ ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> {
+ self.run_command(|response_sender| {
+ AppSdkWorkerCommand::EnqueueOrderDecision(request, response_sender)
+ })
+ }
+
pub fn begin_projection_rebuild(
&self,
) -> Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeError> {
@@ -955,6 +985,17 @@ fn run_app_sdk_worker(
};
send_worker_result(&shared, response_sender, result);
}
+ AppSdkWorkerCommand::EnqueueOrderDecision(request, response_sender) => {
+ let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
+ Err(issue)
+ } else {
+ match sdk.as_ref() {
+ Some(sdk) => enqueue_order_decision_with_sdk(&runtime, sdk, request),
+ None => Err(runtime_unavailable_issue(&shared)),
+ }
+ };
+ send_worker_result(&shared, response_sender, result);
+ }
AppSdkWorkerCommand::BeginProjectionRebuild(response_sender) => {
let result = match sdk.as_ref() {
Some(_) => Ok(begin_projection_rebuild(&shared)),
@@ -1036,6 +1077,13 @@ fn run_degraded_worker(
Err(runtime_unavailable_issue(&shared)),
);
}
+ AppSdkWorkerCommand::EnqueueOrderDecision(_, response_sender) => {
+ send_worker_result(
+ &shared,
+ response_sender,
+ Err(runtime_unavailable_issue(&shared)),
+ );
+ }
AppSdkWorkerCommand::BeginProjectionRebuild(response_sender) => {
send_worker_result(
&shared,
@@ -1172,6 +1220,46 @@ fn enqueue_order_submit_with_sdk(
Ok(app_sdk_order_receipt(receipt, request.actor_pubkey))
}
+fn enqueue_order_decision_with_sdk(
+ runtime: &tokio::runtime::Runtime,
+ sdk: &RadrootsSdk,
+ request: AppSdkOrderDecisionRequest,
+) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> {
+ let actor = sdk_actor_context(
+ request.actor_pubkey.as_str(),
+ request.actor_account_id.as_str(),
+ RadrootsActorRole::Seller,
+ )?;
+ let signer = sdk_local_signer(request.signer_keys)?;
+ let target_relays = sdk_relay_targets(request.target_relays, request.relay_url_policy)?;
+ runtime
+ .block_on(
+ sdk.orders()
+ .ingest_request_evidence(OrderRequestEvidenceIngestRequest::new(
+ request.request_event,
+ )),
+ )
+ .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
+ let mut enqueue = OrderDecisionEnqueueRequest::new(
+ actor,
+ request.request_event_ptr,
+ request.decision,
+ target_relays,
+ );
+ if let Some(idempotency_key) = request.idempotency_key.as_deref() {
+ enqueue = enqueue
+ .try_with_idempotency_key(idempotency_key)
+ .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
+ }
+ let receipt = runtime
+ .block_on(sdk.orders().enqueue_decision(enqueue, &signer))
+ .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
+ Ok(app_sdk_order_decision_receipt(
+ receipt,
+ request.actor_pubkey,
+ ))
+}
+
fn sdk_actor_context(
actor_pubkey: &str,
actor_account_id: &str,
@@ -1230,6 +1318,22 @@ fn app_sdk_order_receipt(
}
}
+fn app_sdk_order_decision_receipt(
+ receipt: OrderDecisionReceipt,
+ actor_pubkey: String,
+) -> AppSdkWorkflowReceipt {
+ AppSdkWorkflowReceipt {
+ operation_kind: ORDER_DECISION_OPERATION_KIND.to_owned(),
+ expected_event_id: receipt.expected_event_id.as_str().to_owned(),
+ signed_event_id: receipt.signed_event_id.as_str().to_owned(),
+ outbox_operation_id: receipt.outbox_operation_id,
+ outbox_event_id: receipt.outbox_event_id,
+ state: sdk_mutation_state_key(receipt.state).to_owned(),
+ idempotency_digest_prefix: receipt.idempotency_digest_prefix,
+ actor_pubkey,
+ }
+}
+
fn sdk_mutation_state_key(state: SdkMutationState) -> &'static str {
match state {
SdkMutationState::StoredAndQueued => "enqueued",
diff --git a/crates/sync/src/publish.rs b/crates/sync/src/publish.rs
@@ -6,7 +6,9 @@ use radroots_sdk::protocol::order::{
RadrootsOrderEconomics, RadrootsOrderFulfillmentState, RadrootsOrderItem,
RadrootsOrderRevisionOutcome,
};
-use radroots_sdk::{FARM_PUBLISH_OPERATION_KIND, ORDER_SUBMIT_OPERATION_KIND};
+use radroots_sdk::{
+ FARM_PUBLISH_OPERATION_KIND, ORDER_DECISION_OPERATION_KIND, ORDER_SUBMIT_OPERATION_KIND,
+};
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -46,7 +48,7 @@ impl AppPublishWorkKind {
Self::FarmProfile => FARM_PUBLISH_OPERATION_KIND,
Self::Listing => "listing.publish_draft_with_identity",
Self::OrderRequest => ORDER_SUBMIT_OPERATION_KIND,
- Self::OrderDecision => "trade.publish_order_decision_with_identity",
+ Self::OrderDecision => ORDER_DECISION_OPERATION_KIND,
Self::OrderRevisionProposal => "trade.publish_order_revision_proposal_with_identity",
Self::OrderRevisionDecision => "trade.publish_order_revision_decision_with_identity",
Self::OrderCancellation => "trade.publish_order_cancellation_with_identity",
@@ -318,9 +320,8 @@ impl AppPublishPayload {
pub const fn legacy_sdk_transport_mode(&self) -> Option<SdkTransportMode> {
match self {
- Self::FarmProfile(_) | Self::OrderRequest(_) => None,
+ Self::FarmProfile(_) | Self::OrderRequest(_) | Self::OrderDecision(_) => None,
Self::Listing(_)
- | Self::OrderDecision(_)
| Self::OrderRevisionProposal(_)
| Self::OrderRevisionDecision(_)
| Self::OrderCancellation(_)
@@ -800,6 +801,7 @@ mod tests {
AppOrderRequestPublishPayload, AppOrderRevisionDecisionPublishPayload,
AppOrderRevisionProposalPublishPayload, AppPublishContext, AppPublishPayload,
AppPublishValidationFailure, AppPublishWorkKind, FARM_PUBLISH_OPERATION_KIND,
+ ORDER_DECISION_OPERATION_KIND,
};
use crate::{
PendingSyncOperation, PendingSyncOperationState, SyncAggregateRef, SyncOperationKind,
@@ -996,7 +998,7 @@ mod tests {
assert_eq!(payload.work_kind().storage_key(), "order_decision");
assert_eq!(
payload.work_kind().sdk_operation(),
- "trade.publish_order_decision_with_identity"
+ ORDER_DECISION_OPERATION_KIND
);
let reason_codes: Vec<&str> = payload
.validation_failures()