app

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

commit f981d6905a74e20414a5102f82c392cffa49a5d8
parent f3be8c83fdb19a29b1077ff8f3d561343940faae
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Jul 2026 16:56:21 +0000

release-product: cut studio to semantic trade envelopes

- route Studio publish and desktop runtime paths through canonical release-product trade mutation envelopes
- remove active-order interop projection and keep validation receipts as passive signed evidence
- migrate the default store away from buyer private plaintext order fields
- expose evidence, conflict, private-terms, and attestation workflow axes in view and i18n surfaces

Diffstat:
MCargo.lock | 3+++
Mcrates/desktop/src/runtime.rs | 2184++++++++++++++++++++++++++++++++-----------------------------------------------
Mcrates/desktop/src/source_guards.rs | 301++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mcrates/desktop/src/window.rs | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/i18n/src/keys.rs | 27+++++++++++++++++++++++++--
Mcrates/i18n/src/lib.rs | 34++++++++++++++++++++++++++++------
Mcrates/runtime/src/lib.rs | 6+++---
Mcrates/runtime/src/sdk.rs | 567+++++++++++++++++++++++++++++++++++++------------------------------------------
Acrates/store/migrations/0031_drop_default_buyer_private_fields.sql | 320+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/store/src/interop.rs | 3121++++---------------------------------------------------------------------------
Mcrates/store/src/lib.rs | 43++++++++++++++++++++-----------------------
Mcrates/store/src/migrations.rs | 4++++
Mcrates/store/src/repo/buyer.rs | 142++++++++++++++++---------------------------------------------------------------
Mcrates/store/src/repo/orders.rs | 9++-------
Mcrates/store/src/repo/workflow.rs | 11++++++++---
Mcrates/sync/src/lib.rs | 10+++++-----
Mcrates/sync/src/publish.rs | 793+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mcrates/view/src/lib.rs | 597+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mi18n/locales/en/messages.json | 27+++++++++++++++++++++++++--
19 files changed, 3008 insertions(+), 5425 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5134,11 +5134,13 @@ dependencies = [ name = "radroots_event_store" version = "0.1.0-alpha.2" dependencies = [ + "hex", "radroots_event", "radroots_nostr", "radroots_transport", "serde", "serde_json", + "sha2", "sqlx", "thiserror 1.0.69", ] @@ -5330,6 +5332,7 @@ dependencies = [ name = "radroots_sdk" version = "0.1.0" dependencies = [ + "base64 0.22.1", "hex", "nostr", "radroots_authority", diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -11,30 +11,31 @@ use radroots_core::{ RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; use radroots_event::{ - RadrootsEventEnvelope, RadrootsEventEnvelopeParts, RadrootsEventPtr, + RadrootsEventEnvelope, RadrootsEventEnvelopeParts, farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}, ids::{ - RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, - RadrootsOrderId, RadrootsPublicKey, + RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, + RadrootsPublicKey, RadrootsTradeId, }, kinds::{ - KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, - KIND_PROFILE, + KIND_FARM, KIND_LISTING, KIND_PROFILE, KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, + KIND_TRADE_PROPOSAL, KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, }, listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus, }, - order::{ - RadrootsOrderDecision, RadrootsOrderEconomics, RadrootsOrderEventType, - RadrootsOrderInventoryCommitment, RadrootsOrderItem, RadrootsOrderRequest, + trade::{ + RADROOTS_TRADE_CANCELLATION_CONTRACT_ID, RADROOTS_TRADE_DECISION_CONTRACT_ID, + RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, + RadrootsFulfillmentProfileV1, RadrootsTradeCancellationProfileV1, + RadrootsTradeCandidateLineV1, RadrootsTradeCandidateTermsV1, RadrootsTradeDecisionV1, + RadrootsTradeEconomicsProfileV1, RadrootsTradeMutationBodyV1, + RadrootsTradeMutationEnvelopeV1, canonical_trade_mutation_content, }, }; -use radroots_event_codec::order::{ - order_cancellation_from_event, order_decision_from_event, order_event_context_from_tags, - order_request_from_event, -}; +use radroots_event_codec::trade::trade_mutation_from_event; use radroots_identity::{RadrootsIdentity, RadrootsIdentityId}; use radroots_nostr::prelude::{ RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrOutput, @@ -51,8 +52,8 @@ use radroots_runtime_store::{ }; use radroots_sdk::{ FARM_PUBLISH_OPERATION_KIND, LISTING_PUBLISH_OPERATION_KIND, NostrRelayUrlPolicy, - TRADE_CANCELLATION_OPERATION_KIND, TRADE_DECISION_OPERATION_KIND, TRADE_SUBMIT_OPERATION_KIND, - TargetSet, + TRADE_CANCEL_OPERATION_KIND, TRADE_DECIDE_CANDIDATE_OPERATION_KIND, + TRADE_SUBMIT_PROPOSAL_OPERATION_KIND, TargetSet, }; use radroots_sql_core::SqlxSqliteExecutor; use radroots_studio_app_core::{ @@ -63,8 +64,8 @@ use radroots_studio_app_core::{ DesktopRuntimeProjectionLifecycleState, DesktopRuntimePublicFarmLocation, DesktopRuntimeRelayUrlPolicy, DesktopRuntimeSnapshot, DesktopRuntimeSupervisor, DesktopRuntimeSupervisorConfig, DesktopRuntimeSupervisorError, - DesktopRuntimeTradeCancellationRequest, DesktopRuntimeTradeDecision, - DesktopRuntimeTradeDecisionRequest, DesktopRuntimeTradeProposeRequest, PackDayExportWriteError, + DesktopRuntimeTradeCancelRequest, DesktopRuntimeTradeDecideCandidateRequest, + DesktopRuntimeTradeSubmitProposalRequest, PackDayExportWriteError, prepare_pack_day_export_bundle_at_data_root, shared_runtime_store_database_path_from_shared_accounts, write_prepared_pack_day_export_bundle, }; @@ -76,9 +77,8 @@ use radroots_studio_app_sqlite::{ AppRelayIngestSuccessInput, AppSqliteError, AppSqliteStore, BuyerOrderRuntimeStoreExport, BuyerOrderRuntimeStoreLine, BuyerRepeatDemandApplyOutcome, DatabaseTarget, DesktopRuntimeWorkflowReceiptInput, DesktopRuntimeWorkflowReceiptSourceKind, - DesktopRuntimeWorkflowReceiptState, SelectedBuyerOrderScope, SellerOrderDecisionExport, - StoredPendingSyncOperation, StoredRelayIngestCursor, StoredSyncConflict, - derive_farm_rules_readiness, projected_order_id_from_trade_request, + DesktopRuntimeWorkflowReceiptState, SelectedBuyerOrderScope, StoredPendingSyncOperation, + StoredRelayIngestCursor, StoredSyncConflict, derive_farm_rules_readiness, }; use radroots_studio_app_state::{ APP_STATE_FILE_NAME, AppShellProjection, AppStateCommand, AppStatePersistenceRepository, @@ -91,12 +91,11 @@ use radroots_studio_app_state::{ derive_sync_projection, }; use radroots_studio_app_sync::{ - AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, - AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, AppOrderDecisionPublishPayload, - AppOrderRequestItemPayload, AppOrderRequestPublishPayload, AppPublishContext, - AppPublishPayload, AppPublishedOperationReceipt, AppRelayIngestScopeFreshness, - AppSyncProjection, AppSyncRequest, AppSyncResult, AppSyncRunStatus, AppSyncTransport, - AppSyncTransportError, SyncCheckpointStatus, SyncConflictSeverity, SyncTrigger, + AppFarmProfilePublishPayload, AppListingPublishPayload, AppPublishContext, AppPublishPayload, + AppPublishedOperationReceipt, AppRelayIngestScopeFreshness, AppSyncProjection, AppSyncRequest, + AppSyncResult, AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, + AppTradeCancellationPublishPayload, AppTradeCandidateDecisionPublishPayload, + AppTradeProposalPublishPayload, SyncCheckpointStatus, SyncConflictSeverity, SyncTrigger, }; #[cfg(test)] use radroots_studio_app_sync::{PendingSyncOperation, SyncAggregateRef, SyncOperationKind}; @@ -117,13 +116,6 @@ use radroots_studio_app_view::{ ReminderUrgency, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, }; -use radroots_trade::identity::RadrootsTradeLocator; -use radroots_trade::listing::parse_public_listing_address; -use radroots_trade::order::{ - RadrootsOrderCancellationRecord, RadrootsOrderDecisionRecord, RadrootsOrderReductionInputs, - RadrootsOrderRequestRecord, reduce_order_events, -}; -use radroots_trade::workflow::RadrootsTradeWorkflowState; use serde_json::json; use thiserror::Error; use tokio::runtime::Builder as TokioRuntimeBuilder; @@ -166,9 +158,11 @@ const APP_DIRECT_RELAY_INGEST_KINDS: &[u16] = &[ KIND_PROFILE as u16, KIND_FARM as u16, KIND_LISTING as u16, - KIND_ORDER_REQUEST as u16, - KIND_ORDER_DECISION as u16, - KIND_ORDER_CANCELLATION as u16, + KIND_TRADE_PROPOSAL as u16, + KIND_TRADE_DECISION as u16, + KIND_TRADE_REVISION_PROPOSAL as u16, + KIND_TRADE_REVISION_DECISION as u16, + KIND_TRADE_CANCELLATION as u16, ]; #[derive(Debug, Default)] @@ -209,42 +203,12 @@ struct AppDirectRelayFetchedRelay { #[derive(Clone, Debug, Eq, PartialEq)] pub enum AppSellerOrderDecisionCommand { Accept, - Decline { - reason: String, - confirm_public_note: bool, - }, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ResolvedAppSellerOrderRequest { - request_event: RadrootsEventEnvelope, - request_event_id: String, - request_author_pubkey: String, - listing_event_id: Option<String>, - payload: RadrootsOrderRequest, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ResolvedAppOrderDecisionEvidence { - event_id: String, - payload: RadrootsOrderDecision, + Decline { reason: String }, } #[derive(Clone, Debug, Eq, PartialEq)] struct ResolvedAppOrderLifecycleEvidence { - request_event_id: String, - status: RadrootsTradeWorkflowState, - agreement_event_id: Option<String>, - last_event_id: Option<String>, - decision: Option<ResolvedAppOrderDecisionEvidence>, - cancellation_event_id: Option<String>, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -struct AppActiveOrderEvidenceBuckets { - requests: Vec<RadrootsOrderRequestRecord>, - decisions: Vec<RadrootsOrderDecisionRecord>, - cancellations: Vec<RadrootsOrderCancellationRecord>, + root: RadrootsTradeMutationEnvelopeV1, } #[derive(Debug, Default)] @@ -349,9 +313,10 @@ fn publish_payload_context(publish_payload: &AppPublishPayload) -> &AppPublishCo match publish_payload { AppPublishPayload::FarmProfile(payload) => &payload.context, AppPublishPayload::Listing(payload) => &payload.context, - AppPublishPayload::OrderRequest(payload) => &payload.context, - AppPublishPayload::OrderDecision(payload) => &payload.context, - AppPublishPayload::OrderCancellation(payload) => &payload.context, + AppPublishPayload::TradeProposal(payload) => &payload.context, + AppPublishPayload::TradeRevisionProposal(payload) => &payload.context, + AppPublishPayload::TradeCandidateDecision(payload) => &payload.context, + AppPublishPayload::TradeCancellation(payload) => &payload.context, } } @@ -778,7 +743,7 @@ impl DesktopAppRuntime { pub fn prepare_order_accept( &self, order_id: OrderId, - ) -> Result<AppOrderDecisionPublishPayload, AppSqliteError> { + ) -> Result<AppTradeCandidateDecisionPublishPayload, AppSqliteError> { self.lock_state_mut() .prepare_seller_order_decision(order_id, AppSellerOrderDecisionCommand::Accept) } @@ -787,13 +752,12 @@ impl DesktopAppRuntime { &self, order_id: OrderId, reason: &str, - confirm_public_note: bool, - ) -> Result<AppOrderDecisionPublishPayload, AppSqliteError> { + _confirm_public_note: bool, + ) -> Result<AppTradeCandidateDecisionPublishPayload, AppSqliteError> { self.lock_state_mut().prepare_seller_order_decision( order_id, AppSellerOrderDecisionCommand::Decline { reason: reason.to_owned(), - confirm_public_note, }, ) } @@ -807,13 +771,12 @@ impl DesktopAppRuntime { &self, order_id: OrderId, reason: &str, - confirm_public_note: bool, + _confirm_public_note: bool, ) -> Result<bool, AppSqliteError> { self.lock_state_mut().publish_seller_order_decision( order_id, AppSellerOrderDecisionCommand::Decline { reason: reason.to_owned(), - confirm_public_note, }, ) } @@ -2458,7 +2421,7 @@ impl DesktopAppRuntimeState { &mut self, order_id: OrderId, command: AppSellerOrderDecisionCommand, - ) -> Result<AppOrderDecisionPublishPayload, AppSqliteError> { + ) -> Result<AppTradeCandidateDecisionPublishPayload, AppSqliteError> { let _ = self.import_shared_runtime_store()?; let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| { AppSqliteError::InvalidProjection { @@ -2497,30 +2460,12 @@ impl DesktopAppRuntimeState { reason: "seller order decision requires a selected seller public key", }, )?; - let request = self.resolve_seller_order_request_evidence(order_id)?; - if request.payload.seller_pubkey.trim() != seller_pubkey.as_str() { + let lifecycle = self.resolve_order_lifecycle_evidence(order_id)?; + if lifecycle.root.seller_pubkey.as_str() != seller_pubkey.as_str() { return Err(AppSqliteError::InvalidProjection { reason: "seller order decision seller account does not match order seller", }); } - let listing_address = parse_public_listing_address(request.payload.listing_addr.as_str()) - .map_err(|_| AppSqliteError::InvalidProjection { - reason: "seller order decision listing address is invalid", - })?; - if listing_address.seller_pubkey.as_str() != seller_pubkey.as_str() { - return Err(AppSqliteError::InvalidProjection { - reason: "seller order decision listing address is outside seller authority", - }); - } - let lifecycle = self.resolve_order_lifecycle_evidence(&request)?; - if lifecycle.decision.is_some() - || lifecycle.status != RadrootsTradeWorkflowState::Requested - || lifecycle.cancellation_event_id.is_some() - { - return Err(AppSqliteError::InvalidProjection { - reason: "seller order decision requires an undecided order", - }); - } let Some(order_export) = sqlite_store.load_seller_order_decision_export(farm_id, order_id)? else { @@ -2534,43 +2479,21 @@ impl DesktopAppRuntimeState { }); } - let confirm_public_note = match &command { - AppSellerOrderDecisionCommand::Accept => false, - AppSellerOrderDecisionCommand::Decline { - confirm_public_note, - .. - } => *confirm_public_note, - }; - let decision = match command { - AppSellerOrderDecisionCommand::Accept => AppOrderDecisionPayload::Accepted { - inventory_commitments: seller_order_inventory_commitments(&order_export)?, - }, - AppSellerOrderDecisionCommand::Decline { reason, .. } => { - let reason = reason.trim(); - if reason.is_empty() { - return Err(AppSqliteError::InvalidProjection { - reason: "seller order decline requires a non-empty reason", - }); - } - AppOrderDecisionPayload::Declined { - reason: reason.to_owned(), - } - } - }; - let payload = AppOrderDecisionPublishPayload { + let acknowledge_private_terms = + seller_decision_acknowledges_private_terms(&lifecycle.root, &command); + let envelope = trade_decision_envelope_from_proposal( + &lifecycle.root, + &command, + current_runtime_time_seconds()?, + )?; + let payload = AppTradeCandidateDecisionPublishPayload { context: AppPublishContext::new(account_id, "seller_order_decision"), - app_order_id: order_id, + order_id, farm_id, - trade_order_id: request.payload.order_id.to_string(), - request_event_id: request.request_event_id, - listing_event_id: request.listing_event_id, - listing_addr: request.payload.listing_addr.to_string(), - buyer_pubkey: request.payload.buyer_pubkey.to_string(), - seller_pubkey: request.payload.seller_pubkey.to_string(), - decision, - confirm_public_note, + envelope, + acknowledge_private_terms, }; - AppPublishPayload::OrderDecision(payload.clone()) + AppPublishPayload::TradeCandidateDecision(payload.clone()) .validate() .map_err(|_| AppSqliteError::InvalidProjection { reason: "seller order decision publish payload is invalid", @@ -2607,8 +2530,8 @@ impl DesktopAppRuntimeState { command: AppSellerOrderDecisionCommand, ) -> Result<bool, AppSqliteError> { let payload = self.prepare_seller_order_decision(order_id, command)?; - let source_record_id = order_decision_sdk_source_record_id(&payload); - self.enqueue_order_decision_payload_via_sdk( + let source_record_id = trade_candidate_decision_sdk_source_record_id(&payload); + self.enqueue_trade_candidate_decision_payload_via_sdk( &payload, DesktopRuntimeWorkflowReceiptSourceKind::AppWorkflow, source_record_id.as_str(), @@ -2620,8 +2543,8 @@ impl DesktopAppRuntimeState { fn prepare_buyer_order_cancellation( &mut self, order_id: OrderId, - confirm_public_note: bool, - ) -> Result<AppOrderCancellationPublishPayload, AppSqliteError> { + _confirm_public_note: bool, + ) -> Result<AppTradeCancellationPublishPayload, AppSqliteError> { let _ = self.import_shared_runtime_store()?; let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| { AppSqliteError::InvalidProjection { @@ -2668,49 +2591,24 @@ impl DesktopAppRuntimeState { reason: "buyer order cancellation requires an open pre-agreement order", }); } - let request = self.resolve_seller_order_request_evidence(order_id)?; - if request.payload.buyer_pubkey.trim() != buyer_pubkey.as_str() { + let lifecycle = self.resolve_order_lifecycle_evidence(order_id)?; + if lifecycle.root.buyer_pubkey.as_str() != buyer_pubkey.as_str() { return Err(AppSqliteError::InvalidProjection { reason: "buyer order cancellation buyer account does not match order buyer", }); } - let lifecycle = self.resolve_order_lifecycle_evidence(&request)?; - if lifecycle.cancellation_event_id.is_some() { - return Err(AppSqliteError::InvalidProjection { - reason: "buyer order cancellation requires an open pre-agreement order", - }); - } - match lifecycle.status { - RadrootsTradeWorkflowState::Requested => {} - RadrootsTradeWorkflowState::AgreedPendingValidation - | RadrootsTradeWorkflowState::Committed => { - return Err(AppSqliteError::InvalidProjection { - reason: "buyer order cancellation requires an open pre-agreement order", - }); - } - RadrootsTradeWorkflowState::Missing - | RadrootsTradeWorkflowState::Declined - | RadrootsTradeWorkflowState::Cancelled - | RadrootsTradeWorkflowState::ValidationExpired - | RadrootsTradeWorkflowState::Invalid => { - return Err(AppSqliteError::InvalidProjection { - reason: "buyer order cancellation requires an open pre-agreement order", - }); - } - }; - let payload = AppOrderCancellationPublishPayload { + let envelope = trade_cancellation_envelope_from_proposal( + &lifecycle.root, + "buyer cancelled order", + current_runtime_time_seconds()?, + )?; + let payload = AppTradeCancellationPublishPayload { context: AppPublishContext::new(account_id.clone(), "buyer_order_cancellation"), - app_order_id: order_id, + order_id, farm_id: detail.farm_id, - trade_order_id: request.payload.order_id.to_string(), - request_event_id: request.request_event_id, - listing_addr: request.payload.listing_addr.to_string(), - buyer_pubkey: request.payload.buyer_pubkey.to_string(), - seller_pubkey: request.payload.seller_pubkey.to_string(), - reason: "buyer cancelled order".to_owned(), - confirm_public_note, + envelope, }; - AppPublishPayload::OrderCancellation(payload.clone()) + AppPublishPayload::TradeCancellation(payload.clone()) .validate() .map_err(|_| AppSqliteError::InvalidProjection { reason: "buyer order cancellation publish payload is invalid", @@ -2724,8 +2622,8 @@ impl DesktopAppRuntimeState { confirm_public_note: bool, ) -> Result<bool, AppSqliteError> { let payload = self.prepare_buyer_order_cancellation(order_id, confirm_public_note)?; - let source_record_id = order_cancellation_sdk_source_record_id(&payload); - self.enqueue_order_cancellation_payload_via_sdk( + let source_record_id = trade_cancellation_sdk_source_record_id(&payload); + self.enqueue_trade_cancellation_payload_via_sdk( &payload, DesktopRuntimeWorkflowReceiptSourceKind::AppWorkflow, source_record_id.as_str(), @@ -4088,8 +3986,8 @@ impl DesktopAppRuntimeState { .context .source_local_event_id .clone() - .unwrap_or_else(|| format!("app:order_request:{}", payload.order_id)); - self.enqueue_order_request_payload_via_sdk( + .unwrap_or_else(|| format!("app:trade_proposal:{}", payload.order_id)); + self.enqueue_trade_proposal_payload_via_sdk( &payload, DesktopRuntimeWorkflowReceiptSourceKind::SharedRuntimeStore, source_record_id.as_str(), @@ -4278,7 +4176,7 @@ impl DesktopAppRuntimeState { buyer_context: &BuyerContext, order: &BuyerOrderRuntimeStoreExport, local_work: Option<&AppOrderLocalWorkPublishSource>, - ) -> Result<Option<AppOrderRequestPublishPayload>, AppSqliteError> { + ) -> Result<Option<AppTradeProposalPublishPayload>, AppSqliteError> { let Some(local_work) = local_work else { return Ok(None); }; @@ -4286,43 +4184,23 @@ impl DesktopAppRuntimeState { return Ok(None); }; let buyer_pubkey = self.runtime_store_owner_pubkey(buyer_account); - let export = AppBuyerOrderRequestExport::from_order(order, buyer_pubkey.as_deref())?; - if !export.is_supported() { - return Ok(None); - } - let Some((currency_code, total_minor_units)) = order_currency_and_total(order)? else { + let Some(buyer_pubkey) = buyer_pubkey.as_deref() else { return Ok(None); }; - let context = AppPublishContext::new( - buyer_account.account.account_id.clone(), - "place_personal_order", - ) - .with_source_local_event_id(local_work.record_id.clone()); - let payload = AppOrderRequestPublishPayload { + let context = + AppPublishContext::new(buyer_account.account.account_id.clone(), "trade_proposal") + .with_source_local_event_id(local_work.record_id.clone()); + let payload = AppTradeProposalPublishPayload { context, order_id: order.order_id, farm_id: order.farm_id, - status: Some(order.status.clone()), - order_document_json: Some(local_work.payload.clone()), - listing_addr: export.listing_addr, - listing_event_id: export.listing_event_id, - listing_relays: export.listing_relays, - buyer_pubkey: export.buyer_pubkey, - seller_pubkey: export.seller_pubkey, - items: order - .lines - .iter() - .map(|line| AppOrderRequestItemPayload { - product_id: line.product_id, - quantity: line.quantity, - }) - .collect(), - currency_code: Some(currency_code), - total_minor_units: Some(total_minor_units), - note: non_empty_string(order.buyer_order_note.as_str()), - confirm_public_note: order.buyer_order_note_public_confirmed, + envelope: trade_proposal_envelope_from_order( + order, + buyer_pubkey, + current_runtime_time_seconds()?, + )?, }; - if AppPublishPayload::OrderRequest(payload.clone()) + if AppPublishPayload::TradeProposal(payload.clone()) .validate() .is_err() { @@ -4429,37 +4307,29 @@ impl DesktopAppRuntimeState { } } - fn enqueue_order_request_payload_via_sdk( + fn enqueue_trade_proposal_payload_via_sdk( &self, - payload: &AppOrderRequestPublishPayload, + payload: &AppTradeProposalPublishPayload, source_kind: DesktopRuntimeWorkflowReceiptSourceKind, source_record_id: &str, ) -> Result<(), AppSqliteError> { - let operation_kind = TRADE_SUBMIT_OPERATION_KIND; + let operation_kind = TRADE_SUBMIT_PROPOSAL_OPERATION_KIND; let actor_pubkey = self - .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderRequest( + .local_signing_identity_for_publish_payload(&AppPublishPayload::TradeProposal( payload.clone(), )) .and_then(|identity| { let actor_pubkey = identity.public_key_hex(); - let order_parts = order_request_publish_payload_to_sdk_product_parts(payload)?; - let request = DesktopRuntimeTradeProposeRequest { + let request = DesktopRuntimeTradeSubmitProposalRequest { actor_account_id: payload.context.account_id.clone(), actor_pubkey: actor_pubkey.clone(), signer: DesktopRuntimeLocalSigner::from_local_identity_keys( identity.into_keys(), ), - listing_event: order_request_sdk_listing_event_ptr(payload)?, - order_id: order_parts.order_id, - listing_addr: order_parts.listing_addr, - seller_pubkey: order_parts.seller_pubkey, - items: order_parts.items, - economics: order_parts.economics, - public_note: payload.note.clone(), - confirm_public_note: payload.confirm_public_note, + envelope: payload.envelope.clone(), idempotency_key: Some(sdk_idempotency_key(source_record_id)), }; - self.enqueue_desktop_runtime_trade_propose(request) + self.enqueue_desktop_runtime_trade_submit_proposal(request) .map(|receipt| (actor_pubkey, receipt)) .map_err(sync_transport_error_from_sdk_runtime_error) }); @@ -4481,31 +4351,30 @@ impl DesktopAppRuntimeState { } } - fn enqueue_order_decision_payload_via_sdk( + fn enqueue_trade_candidate_decision_payload_via_sdk( &self, - payload: &AppOrderDecisionPublishPayload, + payload: &AppTradeCandidateDecisionPublishPayload, source_kind: DesktopRuntimeWorkflowReceiptSourceKind, source_record_id: &str, ) -> Result<(), AppSqliteError> { - let operation_kind = TRADE_DECISION_OPERATION_KIND; + let operation_kind = TRADE_DECIDE_CANDIDATE_OPERATION_KIND; let actor_pubkey = self - .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderDecision( + .local_signing_identity_for_publish_payload(&AppPublishPayload::TradeCandidateDecision( payload.clone(), )) .and_then(|identity| { let actor_pubkey = identity.public_key_hex(); - let request = DesktopRuntimeTradeDecisionRequest { + let request = DesktopRuntimeTradeDecideCandidateRequest { actor_account_id: payload.context.account_id.clone(), actor_pubkey: actor_pubkey.clone(), signer: DesktopRuntimeLocalSigner::from_local_identity_keys( identity.into_keys(), ), - locator: trade_locator_from_decision_payload(payload)?, - decision: trade_decision_from_publish_payload(payload)?, - confirm_public_note: payload.confirm_public_note, + envelope: payload.envelope.clone(), + acknowledge_private_terms: payload.acknowledge_private_terms, idempotency_key: Some(sdk_idempotency_key(source_record_id)), }; - self.enqueue_desktop_runtime_trade_decision(request) + self.enqueue_desktop_runtime_trade_decide_candidate(request) .map(|receipt| (actor_pubkey, receipt)) .map_err(sync_transport_error_from_sdk_runtime_error) }); @@ -4527,28 +4396,26 @@ impl DesktopAppRuntimeState { } } - fn enqueue_order_cancellation_payload_via_sdk( + fn enqueue_trade_cancellation_payload_via_sdk( &self, - payload: &AppOrderCancellationPublishPayload, + payload: &AppTradeCancellationPublishPayload, source_kind: DesktopRuntimeWorkflowReceiptSourceKind, source_record_id: &str, ) -> Result<(), AppSqliteError> { - let operation_kind = TRADE_CANCELLATION_OPERATION_KIND; + let operation_kind = TRADE_CANCEL_OPERATION_KIND; let actor_pubkey = self - .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderCancellation( + .local_signing_identity_for_publish_payload(&AppPublishPayload::TradeCancellation( payload.clone(), )) .and_then(|identity| { let actor_pubkey = identity.public_key_hex(); - let request = DesktopRuntimeTradeCancellationRequest { + let request = DesktopRuntimeTradeCancelRequest { actor_account_id: payload.context.account_id.clone(), actor_pubkey: actor_pubkey.clone(), signer: DesktopRuntimeLocalSigner::from_local_identity_keys( identity.into_keys(), ), - locator: trade_locator_from_cancellation_payload(payload)?, - reason: payload.reason.clone(), - confirm_public_note: payload.confirm_public_note, + envelope: payload.envelope.clone(), idempotency_key: Some(sdk_idempotency_key(source_record_id)), }; self.enqueue_desktop_runtime_trade_cancellation(request) @@ -4597,23 +4464,23 @@ impl DesktopAppRuntimeState { self.with_desktop_runtime_supervisor(|runtime| runtime.enqueue_listing_publish(request)) } - fn enqueue_desktop_runtime_trade_propose( + fn enqueue_desktop_runtime_trade_submit_proposal( &self, - request: DesktopRuntimeTradeProposeRequest, + request: DesktopRuntimeTradeSubmitProposalRequest, ) -> Result<DesktopRuntimeEffectReceipt, DesktopRuntimeSupervisorError> { - self.with_desktop_runtime_supervisor(|runtime| runtime.trade_propose(request)) + self.with_desktop_runtime_supervisor(|runtime| runtime.trade_submit_proposal(request)) } - fn enqueue_desktop_runtime_trade_decision( + fn enqueue_desktop_runtime_trade_decide_candidate( &self, - request: DesktopRuntimeTradeDecisionRequest, + request: DesktopRuntimeTradeDecideCandidateRequest, ) -> Result<DesktopRuntimeEffectReceipt, DesktopRuntimeSupervisorError> { - self.with_desktop_runtime_supervisor(|runtime| runtime.trade_decide(request)) + self.with_desktop_runtime_supervisor(|runtime| runtime.trade_decide_candidate(request)) } fn enqueue_desktop_runtime_trade_cancellation( &self, - request: DesktopRuntimeTradeCancellationRequest, + request: DesktopRuntimeTradeCancelRequest, ) -> Result<DesktopRuntimeEffectReceipt, DesktopRuntimeSupervisorError> { self.with_desktop_runtime_supervisor(|runtime| runtime.trade_cancel(request)) } @@ -4982,270 +4849,54 @@ impl DesktopAppRuntimeState { sqlite_store.import_shared_runtime_store_from_path(database_path.as_path()) } - fn resolve_seller_order_request_evidence( - &self, - order_id: OrderId, - ) -> Result<ResolvedAppSellerOrderRequest, AppSqliteError> { - let mut matched_requests = BTreeMap::new(); - self.collect_seller_order_request_evidence_from_shared_events( - &order_id, - &mut matched_requests, - )?; - self.collect_seller_order_request_evidence_from_local_interop( - &order_id, - &mut matched_requests, - )?; - - if matched_requests.len() > 1 { - return Err(AppSqliteError::InvalidProjection { - reason: "seller order decision found multiple signed order requests", - }); - } - - matched_requests - .into_values() - .next() - .ok_or(AppSqliteError::InvalidProjection { - reason: "seller order decision requires signed order request evidence", - }) - } - - fn collect_seller_order_request_evidence_from_shared_events( - &self, - order_id: &OrderId, - matched_requests: &mut BTreeMap<String, ResolvedAppSellerOrderRequest>, - ) -> Result<(), AppSqliteError> { - let store = self.open_shared_runtime_store_store()?; - let Some(store) = store else { - return Ok(()); - }; - let mut before = None; - - loop { - let records = match before { - Some((before_change_seq, before_seq)) => store - .list_records_changed_before( - before_change_seq, - before_seq, - APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE, - ) - .map_err(|source| AppSqliteError::RuntimeStore { - operation: "load shared order request evidence", - source, - })?, - None => store - .list_records_changed_latest(APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE) - .map_err(|source| AppSqliteError::RuntimeStore { - operation: "load shared order request evidence", - source, - })?, - }; - if records.is_empty() { - break; - } - let is_last_page = - records.len() < APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE as usize; - before = records.last().map(|record| (record.change_seq, record.seq)); - - for record in records { - if record.family != RuntimeStoreRecordFamily::SignedEvent - || record.event_kind - != Some(i64::from(RadrootsOrderEventType::OrderRequested.kind())) - || !signed_order_request_evidence_record_is_usable(&record) - { - continue; - } - let Some(event) = signed_event_from_local_record(&record)? else { - continue; - }; - let Ok(envelope) = order_request_from_event(&event) else { - continue; - }; - insert_seller_order_request_evidence( - order_id, - &event, - envelope.payload, - matched_requests, - ); - } - - if before.is_none() || is_last_page { - break; - } - } - - Ok(()) - } - - fn collect_seller_order_request_evidence_from_local_interop( - &self, - order_id: &OrderId, - matched_requests: &mut BTreeMap<String, ResolvedAppSellerOrderRequest>, - ) -> Result<(), AppSqliteError> { - let Some(sqlite_store) = self.sqlite_store.as_ref() else { - return Ok(()); - }; - let events = sqlite_store.load_local_interop_signed_events_by_kind(i64::from( - RadrootsOrderEventType::OrderRequested.kind(), - ))?; - - for event in events { - let Ok(envelope) = order_request_from_event(&event) else { - continue; - }; - insert_seller_order_request_evidence( - order_id, - &event, - envelope.payload, - matched_requests, - ); - } - - Ok(()) - } - fn resolve_order_lifecycle_evidence( &self, - request: &ResolvedAppSellerOrderRequest, + order_id: OrderId, ) -> Result<ResolvedAppOrderLifecycleEvidence, AppSqliteError> { - let mut events = self.collect_order_lifecycle_signed_events()?; - events.sort_by(|left, right| { - left.created_at_u64() - .cmp(&right.created_at_u64()) - .then_with(|| left.id_str().cmp(right.id_str())) - }); - - let mut buckets = AppActiveOrderEvidenceBuckets::default(); - let request_event_id = - active_order_event_id(request.request_event_id.as_str(), "request_event_id")?; - let request_author_pubkey = active_order_pubkey( - request.request_author_pubkey.as_str(), - "request_author_pubkey", - )?; - buckets.requests.push(RadrootsOrderRequestRecord { - event_id: request_event_id.clone(), - author_pubkey: request_author_pubkey, - payload: request.payload.clone(), + let target_trade_id = trade_id_from_order_id(order_id)?; + let mut matches = self + .collect_trade_proposal_envelopes()? + .into_iter() + .filter(|envelope| envelope.trade_id == target_trade_id) + .collect::<Vec<_>>(); + matches.sort_by(|left, right| { + left.authored_at_unix_s + .cmp(&right.authored_at_unix_s) + .then_with(|| left.mutation_id.cmp(&right.mutation_id)) }); - for event in events { - if trade_chain_tag_value(&event, "e_root").as_deref() - != Some(request.request_event_id.as_str()) - { - continue; - } - let event_id = active_order_event_id(event.id_str(), "event_id")?; - let author_pubkey = active_order_pubkey(event.author_str(), "author_pubkey")?; - match event.kind_u32() { - KIND_ORDER_DECISION => { - let envelope = order_decision_from_event(&event).map_err(|_| { - AppSqliteError::InvalidProjection { - reason: "order lifecycle evidence is invalid", - } - })?; - let context = active_order_event_record_context(&event, envelope.message_type)?; - buckets.decisions.push(RadrootsOrderDecisionRecord { - event_id, - author_pubkey, - counterparty_pubkey: context.0, - root_event_id: context.1, - prev_event_id: context.2, - payload: envelope.payload, - }); - } - KIND_ORDER_CANCELLATION => { - let Ok(envelope) = order_cancellation_from_event(&event) else { - return Err(AppSqliteError::InvalidProjection { - reason: "order lifecycle evidence is invalid", - }); - }; - let context = active_order_event_record_context(&event, envelope.message_type)?; - buckets.cancellations.push(RadrootsOrderCancellationRecord { - event_id, - author_pubkey, - counterparty_pubkey: context.0, - root_event_id: context.1, - prev_event_id: context.2, - payload: envelope.payload, - }); - } - _ => {} - } - } - - let projection = reduce_order_events( - &request.payload.order_id, - RadrootsOrderReductionInputs { - requests: buckets.requests.clone(), - decisions: buckets.decisions.clone(), - cancellations: buckets.cancellations.clone(), - }, - ); - if !projection.issues.is_empty() || projection.status == RadrootsTradeWorkflowState::Invalid - { - return Err(AppSqliteError::InvalidProjection { - reason: "order lifecycle evidence is invalid", - }); - } - if projection.request_event_id.as_ref() != Some(&request_event_id) { + matches.dedup_by(|left, right| left.mutation_id == right.mutation_id); + if matches.len() > 1 { return Err(AppSqliteError::InvalidProjection { - reason: "order lifecycle evidence is invalid", + reason: "trade lifecycle evidence has multiple root proposals", }); } - - let decision = projection - .decision_event_id - .as_ref() - .map(|event_id| { - buckets - .decisions - .iter() - .find(|decision| decision.event_id == *event_id) - .map(|decision| ResolvedAppOrderDecisionEvidence { - event_id: decision.event_id.to_string(), - payload: decision.payload.clone(), - }) - .ok_or(AppSqliteError::InvalidProjection { - reason: "order lifecycle evidence is invalid", - }) - }) - .transpose()?; Ok(ResolvedAppOrderLifecycleEvidence { - request_event_id: request.request_event_id.clone(), - status: projection.status, - agreement_event_id: projection - .agreement_event_id - .map(|event_id| event_id.to_string()), - last_event_id: projection - .last_event_id - .map(|event_id| event_id.to_string()), - decision, - cancellation_event_id: projection - .cancellation_event_id - .map(|event_id| event_id.to_string()), + root: matches.pop().ok_or(AppSqliteError::InvalidProjection { + reason: "trade lifecycle requires signed proposal evidence", + })?, }) } - fn collect_order_lifecycle_signed_events( + fn collect_trade_proposal_envelopes( &self, - ) -> Result<Vec<RadrootsEventEnvelope>, AppSqliteError> { - let mut events = Vec::new(); + ) -> Result<Vec<RadrootsTradeMutationEnvelopeV1>, AppSqliteError> { + let mut envelopes = Vec::new(); let mut seen_event_ids = BTreeSet::new(); - let kinds = [KIND_ORDER_DECISION, KIND_ORDER_CANCELLATION]; if let Some(sqlite_store) = self.sqlite_store.as_ref() { - for kind in kinds { - for event in - sqlite_store.load_local_interop_signed_events_by_kind(i64::from(kind))? + for event in sqlite_store + .load_local_interop_signed_events_by_kind(i64::from(KIND_TRADE_PROPOSAL))? + { + if seen_event_ids.insert(event.id_str().to_owned()) + && let Ok(envelope) = trade_mutation_from_event(&event) { - if seen_event_ids.insert(event.id_str().to_owned()) { - events.push(event); - } + envelopes.push(envelope); } } } let Some(store) = self.open_shared_runtime_store_store()? else { - return Ok(events); + return Ok(envelopes); }; let mut before = None; loop { @@ -5257,13 +4908,13 @@ impl DesktopAppRuntimeState { APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE, ) .map_err(|source| AppSqliteError::RuntimeStore { - operation: "load shared order lifecycle evidence", + operation: "load shared trade proposal evidence", source, })?, None => store .list_records_changed_latest(APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE) .map_err(|source| AppSqliteError::RuntimeStore { - operation: "load shared order lifecycle evidence", + operation: "load shared trade proposal evidence", source, })?, }; @@ -5279,16 +4930,18 @@ impl DesktopAppRuntimeState { continue; }; if record.family != RuntimeStoreRecordFamily::SignedEvent - || !kinds.contains(&u32::try_from(kind).unwrap_or_default()) - || !signed_order_request_evidence_record_is_usable(&record) + || u32::try_from(kind).ok() != Some(KIND_TRADE_PROPOSAL) + || !signed_event_evidence_record_is_usable(&record) { continue; } let Some(event) = signed_event_from_local_record(&record)? else { continue; }; - if seen_event_ids.insert(event.id_str().to_owned()) { - events.push(event); + if seen_event_ids.insert(event.id_str().to_owned()) + && let Ok(envelope) = trade_mutation_from_event(&event) + { + envelopes.push(envelope); } } @@ -5297,7 +4950,7 @@ impl DesktopAppRuntimeState { } } - Ok(events) + Ok(envelopes) } fn open_shared_runtime_store_store( @@ -5566,7 +5219,7 @@ impl DesktopAppRuntimeState { timestamp, ); if sqlite_store.buyer_order_coordination_is_synced(buyer_context, order.order_id)? { - return Ok(Some(AppOrderLocalWorkPublishSource { record_id, payload })); + return Ok(Some(AppOrderLocalWorkPublishSource { record_id })); } validate_buyer_order_request_local_work_payload(&payload).map_err(|source| { AppSqliteError::RuntimeStore { @@ -5619,7 +5272,7 @@ impl DesktopAppRuntimeState { return Err(error); } sqlite_store.mark_buyer_order_coordination_synced(buyer_context, order.order_id)?; - Ok(Some(AppOrderLocalWorkPublishSource { record_id, payload })) + Ok(Some(AppOrderLocalWorkPublishSource { record_id })) } fn append_app_local_work_record( @@ -6834,12 +6487,14 @@ fn listing_publish_source_record( }) } -fn order_decision_sdk_source_record_id(payload: &AppOrderDecisionPublishPayload) -> String { - format!("app:order_decision:{}", payload.app_order_id) +fn trade_candidate_decision_sdk_source_record_id( + payload: &AppTradeCandidateDecisionPublishPayload, +) -> String { + format!("app:trade_candidate_decision:{}", payload.order_id) } -fn order_cancellation_sdk_source_record_id(payload: &AppOrderCancellationPublishPayload) -> String { - format!("app:order_cancellation:{}", payload.app_order_id) +fn trade_cancellation_sdk_source_record_id(payload: &AppTradeCancellationPublishPayload) -> String { + format!("app:trade_cancellation:{}", payload.order_id) } fn sdk_relay_url_policy_for_targets(target_relays: &[String]) -> DesktopRuntimeRelayUrlPolicy { @@ -7137,27 +6792,6 @@ fn parse_app_listing_delivery_method( } } -fn order_request_sdk_listing_event_ptr( - payload: &AppOrderRequestPublishPayload, -) -> Result<RadrootsEventPtr, AppSyncTransportError> { - let listing_event_id = payload - .listing_event_id - .as_deref() - .ok_or_else(|| { - AppSyncTransportError::failed("order request publish requires listing event id") - })? - .trim() - .to_owned(); - let listing_relay = normalized_listing_relays(payload.listing_relays.as_slice())? - .into_iter() - .next(); - - Ok(RadrootsEventPtr { - id: listing_event_id, - relays: listing_relay, - }) -} - #[cfg(test)] fn selected_listing_relay( listing_relays: &[String], @@ -7175,6 +6809,7 @@ fn selected_listing_relay( Err(missing_listing_provenance_relay_error(&known_relays)) } +#[cfg(test)] fn normalized_listing_relays( listing_relays: &[String], ) -> Result<Vec<String>, AppSyncTransportError> { @@ -7205,49 +6840,6 @@ fn missing_listing_provenance_relay_error(known_relays: &[String]) -> AppSyncTra ) } -struct SdkOrderRequestProductParts { - order_id: RadrootsOrderId, - listing_addr: RadrootsListingAddress, - seller_pubkey: RadrootsPublicKey, - items: Vec<RadrootsOrderItem>, - economics: RadrootsOrderEconomics, -} - -fn order_request_publish_payload_to_sdk_product_parts( - payload: &AppOrderRequestPublishPayload, -) -> Result<SdkOrderRequestProductParts, AppSyncTransportError> { - let Some(document_json) = payload.order_document_json.as_ref() else { - return Err(AppSyncTransportError::failed( - "order request publish requires order document", - )); - }; - let order_json = document_json.pointer("/document/order").ok_or_else(|| { - AppSyncTransportError::failed("order request publish document is missing order") - })?; - let items_json = order_json.get("items").ok_or_else(|| { - AppSyncTransportError::failed("order request publish document is missing order items") - })?; - let economics_json = order_json.get("economics").ok_or_else(|| { - AppSyncTransportError::failed("order request publish document is missing order economics") - })?; - let listing_addr = payload.listing_addr.as_deref().ok_or_else(|| { - AppSyncTransportError::failed("order request publish requires listing address") - })?; - let seller_pubkey = payload.seller_pubkey.as_deref().ok_or_else(|| { - AppSyncTransportError::failed("order request publish requires seller pubkey") - })?; - - Ok(SdkOrderRequestProductParts { - order_id: publish_order_id(payload.order_id.to_string().as_str())?, - listing_addr: publish_listing_addr(listing_addr)?, - seller_pubkey: publish_pubkey(seller_pubkey)?, - items: serde_json::from_value::<Vec<RadrootsOrderItem>>(items_json.clone()) - .map_err(|error| AppSyncTransportError::failed(error.to_string()))?, - economics: serde_json::from_value::<RadrootsOrderEconomics>(economics_json.clone()) - .map_err(|error| AppSyncTransportError::failed(error.to_string()))?, - }) -} - fn d_tag_from_uuid(uuid: Uuid) -> String { base64_url_no_pad(uuid.as_bytes()) } @@ -7370,7 +6962,6 @@ struct AppBuyerOrderRequestExport { #[derive(Clone, Debug)] struct AppOrderLocalWorkPublishSource { record_id: String, - payload: serde_json::Value, } impl AppBuyerOrderRequestExport { @@ -7523,10 +7114,6 @@ fn buyer_order_request_local_work_payload( "status": order.status, "buyer_context_key": order.buyer_context_key, "buyer_name": order.buyer_name, - "buyer_email": order.buyer_email, - "buyer_phone": order.buyer_phone, - "buyer_order_note": order.buyer_order_note, - "buyer_order_note_public_confirmed": order.buyer_order_note_public_confirmed, "fulfillment": { "window_id": order.fulfillment_window_id.map(|id| id.to_string()), "label": order.fulfillment_window_label, @@ -7636,42 +7223,398 @@ fn order_economics_json( }))) } -fn order_currency_and_total( +fn trade_proposal_envelope_from_order( order: &BuyerOrderRuntimeStoreExport, -) -> Result<Option<(String, u32)>, AppSqliteError> { - let mut currency = None::<String>; - let mut total_minor_units = 0_u32; + buyer_pubkey: &str, + authored_at_unix_s: i64, +) -> Result<RadrootsTradeMutationEnvelopeV1, AppSqliteError> { + let buyer_pubkey = parse_trade_pubkey(buyer_pubkey, "trade proposal requires buyer pubkey")?; + let seller_pubkey = parse_trade_pubkey( + shared_optional_line_value(&order.lines, |line| line.seller_pubkey.as_deref()) + .as_deref() + .ok_or(AppSqliteError::InvalidProjection { + reason: "trade proposal requires seller pubkey", + })?, + "trade proposal requires seller pubkey", + )?; + let farm_id = RadrootsDTag::parse( + shared_optional_line_value(&order.lines, |line| line.farm_key.as_deref()) + .unwrap_or_else(|| d_tag_from_uuid(order.farm_id.as_uuid())) + .as_str(), + ) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "trade proposal requires valid farm id", + })?; + let candidate = + trade_candidate_terms_from_order(order, &buyer_pubkey, &seller_pubkey, &farm_id)?; + let authored_at_unix_s = + u64::try_from(authored_at_unix_s).map_err(|_| AppSqliteError::InvalidProjection { + reason: "trade proposal timestamp must be non-negative", + })?; + canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_owned(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: trade_id_from_order_id(order.order_id)?, + root_mutation_id: None, + buyer_pubkey: buyer_pubkey.clone(), + seller_pubkey: seller_pubkey.clone(), + farm_id, + parent_mutation_ids: Vec::new(), + author_pubkey: buyer_pubkey, + counterparty_pubkey: seller_pubkey, + authored_at_unix_s, + body: RadrootsTradeMutationBodyV1::Proposal { candidate }, + }) + .map(|canonical| canonical.envelope) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "trade proposal envelope is invalid", + }) +} - for line in &order.lines { - let Some(unit_price_minor_units) = line.unit_price_minor_units else { - return Ok(None); - }; +fn trade_candidate_terms_from_order( + order: &BuyerOrderRuntimeStoreExport, + buyer_pubkey: &RadrootsPublicKey, + seller_pubkey: &RadrootsPublicKey, + farm_id: &RadrootsDTag, +) -> Result<RadrootsTradeCandidateTermsV1, AppSqliteError> { + if order.lines.is_empty() { + return Err(AppSqliteError::InvalidProjection { + reason: "trade proposal requires order lines", + }); + } + let mut lines = Vec::with_capacity(order.lines.len()); + let mut currency = None::<String>; + let mut subtotal_minor_units = 0_u32; + for (index, line) in order.lines.iter().enumerate() { + let listing_addr = RadrootsAddressableCoordinate::parse( + line.listing_addr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(AppSqliteError::InvalidProjection { + reason: "trade proposal requires listing address", + })?, + ) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "trade proposal requires valid listing address", + })?; + let listing_event_id = RadrootsEventId::parse( + line.listing_event_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(AppSqliteError::InvalidProjection { + reason: "trade proposal requires listing event id", + })?, + ) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "trade proposal requires valid listing event id", + })?; + let bin_id = RadrootsInventoryBinId::parse( + line.listing_bin_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(AppSqliteError::InvalidProjection { + reason: "trade proposal requires listing bin id", + })?, + ) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "trade proposal requires valid listing bin id", + })?; + if line.quantity == 0 { + return Err(AppSqliteError::InvalidProjection { + reason: "trade proposal requires positive quantities", + }); + } + let unit_code = canonical_quantity_unit(line.quantity_unit_label.as_str()).ok_or( + AppSqliteError::InvalidProjection { + reason: "trade proposal requires canonical quantity units", + }, + )?; + let unit_price_minor_units = + line.unit_price_minor_units + .ok_or(AppSqliteError::InvalidProjection { + reason: "trade proposal requires unit prices", + })?; + if unit_price_minor_units == 0 { + return Err(AppSqliteError::InvalidProjection { + reason: "trade proposal requires positive unit prices", + }); + } let line_currency = normalize_currency_code(line.price_currency.as_str()); if line_currency.len() != 3 || !line_currency.bytes().all(|byte| byte.is_ascii_uppercase()) { - return Ok(None); + return Err(AppSqliteError::InvalidProjection { + reason: "trade proposal requires canonical currency", + }); } if let Some(existing_currency) = currency.as_deref() { if existing_currency != line_currency { - return Ok(None); + return Err(AppSqliteError::InvalidProjection { + reason: "trade proposal requires a single currency", + }); } } else { currency = Some(line_currency.clone()); } - let line_total = unit_price_minor_units.checked_mul(line.quantity).ok_or( + let line_subtotal_minor_units = unit_price_minor_units.checked_mul(line.quantity).ok_or( AppSqliteError::InvalidProjection { - reason: "buyer order publish line total overflowed", + reason: "trade proposal line subtotal overflowed", }, )?; - total_minor_units = - total_minor_units - .checked_add(line_total) - .ok_or(AppSqliteError::InvalidProjection { - reason: "buyer order publish total overflowed", - })?; + subtotal_minor_units = subtotal_minor_units + .checked_add(line_subtotal_minor_units) + .ok_or(AppSqliteError::InvalidProjection { + reason: "trade proposal subtotal overflowed", + })?; + lines.push(RadrootsTradeCandidateLineV1 { + line_id: RadrootsDTag::parse(format!("line-{index}-{}", line.product_id).as_str()) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "trade proposal requires valid line ids", + })?, + listing_addr, + listing_event_id: listing_event_id.clone(), + listing_snapshot_sha256: listing_event_id.to_string(), + product_id: line.product_id.to_string(), + option_id: None, + bin_id, + quantity_mantissa: line.quantity.to_string(), + quantity_scale: 0, + unit_code: unit_code.to_owned(), + unit_profile: "app-cart".to_owned(), + unit_price_mantissa: unit_price_minor_units.to_string(), + currency_code: line_currency.clone(), + line_subtotal_mantissa: line_subtotal_minor_units.to_string(), + replaces_line_id: None, + }); + } + let currency = currency.ok_or(AppSqliteError::InvalidProjection { + reason: "trade proposal requires currency", + })?; + let starts_at_unix_s = parse_order_fulfillment_time(order.fulfillment_starts_at.as_deref())?; + let ends_at_unix_s = parse_order_fulfillment_time(order.fulfillment_ends_at.as_deref())?; + if starts_at_unix_s >= ends_at_unix_s { + return Err(AppSqliteError::InvalidProjection { + reason: "trade proposal requires valid fulfillment window", + }); } + Ok(RadrootsTradeCandidateTermsV1 { + candidate_id: None, + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + base_candidate_id: None, + supersession_intent: None, + buyer_pubkey: buyer_pubkey.clone(), + seller_pubkey: seller_pubkey.clone(), + farm_id: farm_id.clone(), + lines, + line_tombstones: Vec::new(), + economics: RadrootsTradeEconomicsProfileV1 { + profile_id: "app-cart".to_owned(), + currency_code: currency, + currency_exponent: 2, + rounding_profile: "minor-units".to_owned(), + subtotal_mantissa: subtotal_minor_units.to_string(), + discount_total_mantissa: "0".to_owned(), + adjustment_total_mantissa: "0".to_owned(), + total_mantissa: subtotal_minor_units.to_string(), + adjustments: Vec::new(), + }, + fulfillment: RadrootsFulfillmentProfileV1 { + profile_id: order + .fulfillment_window_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "app-fulfillment-window".to_owned()), + method: "pickup".to_owned(), + starts_at_unix_s, + ends_at_unix_s, + timezone: "UTC".to_owned(), + utc_offset_seconds: 0, + fold: 0, + location_class: "farm".to_owned(), + requires_private_terms: false, + }, + cancellation: RadrootsTradeCancellationProfileV1 { + profile_id: "buyer-pre-agreement".to_owned(), + buyer_pre_agreement: true, + post_agreement_cutoff_unix_s: None, + }, + private_terms: None, + proposal_expires_at_unix_s: starts_at_unix_s.saturating_sub(1), + }) +} - Ok(currency.map(|currency| (currency, total_minor_units))) +fn trade_decision_envelope_from_proposal( + proposal: &RadrootsTradeMutationEnvelopeV1, + command: &AppSellerOrderDecisionCommand, + authored_at_unix_s: i64, +) -> Result<RadrootsTradeMutationEnvelopeV1, AppSqliteError> { + let proposal_mutation_id = + proposal + .mutation_id + .clone() + .ok_or(AppSqliteError::InvalidProjection { + reason: "trade decision requires proposal mutation id", + })?; + let candidate = proposal_candidate_terms(proposal)?; + let candidate_id = candidate + .candidate_id + .clone() + .ok_or(AppSqliteError::InvalidProjection { + reason: "trade decision requires candidate id", + })?; + let decision = match command { + AppSellerOrderDecisionCommand::Accept => RadrootsTradeDecisionV1::Accepted { + reservation_assertion: None, + }, + AppSellerOrderDecisionCommand::Decline { reason } => { + let reason = reason.trim(); + if reason.is_empty() { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order decline requires a non-empty reason", + }); + } + RadrootsTradeDecisionV1::Declined { + reason: reason.to_owned(), + } + } + }; + let authored_at_unix_s = + u64::try_from(authored_at_unix_s).map_err(|_| AppSqliteError::InvalidProjection { + reason: "trade decision timestamp must be non-negative", + })?; + canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: RADROOTS_TRADE_DECISION_CONTRACT_ID.to_owned(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: proposal.trade_id.clone(), + root_mutation_id: proposal.mutation_id.clone(), + buyer_pubkey: proposal.buyer_pubkey.clone(), + seller_pubkey: proposal.seller_pubkey.clone(), + farm_id: proposal.farm_id.clone(), + parent_mutation_ids: vec![proposal_mutation_id.clone()], + author_pubkey: proposal.seller_pubkey.clone(), + counterparty_pubkey: proposal.buyer_pubkey.clone(), + authored_at_unix_s, + body: RadrootsTradeMutationBodyV1::Decision { + proposal_mutation_id, + candidate_id, + decision, + }, + }) + .map(|canonical| canonical.envelope) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "trade decision envelope is invalid", + }) +} + +fn trade_cancellation_envelope_from_proposal( + proposal: &RadrootsTradeMutationEnvelopeV1, + reason: &str, + authored_at_unix_s: i64, +) -> Result<RadrootsTradeMutationEnvelopeV1, AppSqliteError> { + let root_mutation_id = + proposal + .mutation_id + .clone() + .ok_or(AppSqliteError::InvalidProjection { + reason: "trade cancellation requires proposal mutation id", + })?; + let candidate_id = proposal_candidate_terms(proposal)? + .candidate_id + .clone() + .ok_or(AppSqliteError::InvalidProjection { + reason: "trade cancellation requires candidate id", + })?; + let reason = reason.trim(); + if reason.is_empty() { + return Err(AppSqliteError::InvalidProjection { + reason: "trade cancellation requires reason", + }); + } + let authored_at_unix_s = + u64::try_from(authored_at_unix_s).map_err(|_| AppSqliteError::InvalidProjection { + reason: "trade cancellation timestamp must be non-negative", + })?; + canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: RADROOTS_TRADE_CANCELLATION_CONTRACT_ID.to_owned(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: proposal.trade_id.clone(), + root_mutation_id: Some(root_mutation_id.clone()), + buyer_pubkey: proposal.buyer_pubkey.clone(), + seller_pubkey: proposal.seller_pubkey.clone(), + farm_id: proposal.farm_id.clone(), + parent_mutation_ids: vec![root_mutation_id], + author_pubkey: proposal.buyer_pubkey.clone(), + counterparty_pubkey: proposal.seller_pubkey.clone(), + authored_at_unix_s, + body: RadrootsTradeMutationBodyV1::Cancellation { + target_candidate_id: Some(candidate_id), + target_claim_mutation_id: None, + reason: reason.to_owned(), + }, + }) + .map(|canonical| canonical.envelope) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "trade cancellation envelope is invalid", + }) +} + +fn proposal_candidate_terms( + proposal: &RadrootsTradeMutationEnvelopeV1, +) -> Result<&RadrootsTradeCandidateTermsV1, AppSqliteError> { + match &proposal.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => Ok(candidate), + _ => Err(AppSqliteError::InvalidProjection { + reason: "trade lifecycle root must be a proposal", + }), + } +} + +fn seller_decision_acknowledges_private_terms( + proposal: &RadrootsTradeMutationEnvelopeV1, + command: &AppSellerOrderDecisionCommand, +) -> bool { + if !matches!(command, AppSellerOrderDecisionCommand::Accept) { + return false; + } + proposal_candidate_terms(proposal) + .ok() + .and_then(|candidate| candidate.private_terms.as_ref()) + .is_some_and(|private_terms| private_terms.required_acknowledgement) +} + +fn trade_id_from_order_id(order_id: OrderId) -> Result<RadrootsTradeId, AppSqliteError> { + let raw = order_id.to_string().replace('-', ""); + RadrootsTradeId::parse(raw.as_str()).map_err(|_| AppSqliteError::InvalidProjection { + reason: "app order id must map to trade id", + }) +} + +fn parse_trade_pubkey( + value: &str, + reason: &'static str, +) -> Result<RadrootsPublicKey, AppSqliteError> { + RadrootsPublicKey::parse(value.trim()).map_err(|_| AppSqliteError::InvalidProjection { reason }) +} + +fn parse_order_fulfillment_time(value: Option<&str>) -> Result<u64, AppSqliteError> { + let value = value + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(AppSqliteError::InvalidProjection { + reason: "trade proposal requires fulfillment timestamps", + })?; + let timestamp = DateTime::parse_from_rfc3339(value) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "trade proposal requires valid fulfillment timestamps", + })? + .timestamp(); + u64::try_from(timestamp).map_err(|_| AppSqliteError::InvalidProjection { + reason: "trade proposal fulfillment timestamp must be non-negative", + }) } fn shared_optional_line_value( @@ -8819,256 +8762,33 @@ fn event_tags_from_value( ) -> Result<Vec<Vec<String>>, AppSqliteError> { let Some(value) = value else { return Ok(Vec::new()); - }; - let Some(tags) = value.as_array() else { - return Err(AppSqliteError::InvalidProjection { - reason: "signed local event tags must be an array", - }); - }; - - tags.iter() - .map(|tag| { - let Some(values) = tag.as_array() else { - return Err(AppSqliteError::InvalidProjection { - reason: "signed local event tag must be an array", - }); - }; - values - .iter() - .map(|value| { - value - .as_str() - .map(str::to_owned) - .ok_or(AppSqliteError::InvalidProjection { - reason: "signed local event tag values must be strings", - }) - }) - .collect() - }) - .collect() -} - -fn trade_chain_tag_value(event: &RadrootsEventEnvelope, key: &str) -> Option<String> { - event.tags_as_vec().iter().find_map(|tag| { - if tag.first().map(String::as_str) == Some(key) { - tag.get(1) - .map(String::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - } else { - None - } - }) -} - -fn active_order_event_id( - value: &str, - field: &'static str, -) -> Result<RadrootsEventId, AppSqliteError> { - value.parse().map_err(|_| AppSqliteError::DecodeId { - field, - value: value.to_owned(), - }) -} - -fn active_order_pubkey( - value: &str, - field: &'static str, -) -> Result<RadrootsPublicKey, AppSqliteError> { - value.parse().map_err(|_| AppSqliteError::DecodeId { - field, - value: value.to_owned(), - }) -} - -fn active_order_event_record_context( - event: &RadrootsEventEnvelope, - message_type: RadrootsOrderEventType, -) -> Result<(RadrootsPublicKey, RadrootsEventId, RadrootsEventId), AppSqliteError> { - let tags = event.tags_as_vec(); - let context = order_event_context_from_tags(message_type, &tags).map_err(|_| { - AppSqliteError::InvalidProjection { - reason: "order lifecycle evidence is invalid", - } - })?; - let root_event_id = context - .root_event_id - .ok_or(AppSqliteError::InvalidProjection { - reason: "order lifecycle evidence is invalid", - })?; - let prev_event_id = context - .prev_event_id - .ok_or(AppSqliteError::InvalidProjection { - reason: "order lifecycle evidence is invalid", - })?; - Ok((context.counterparty_pubkey, root_event_id, prev_event_id)) -} - -fn insert_seller_order_request_evidence( - order_id: &OrderId, - event: &RadrootsEventEnvelope, - payload: RadrootsOrderRequest, - matched_requests: &mut BTreeMap<String, ResolvedAppSellerOrderRequest>, -) { - let app_order_id = projected_order_id_from_trade_request( - payload.order_id.as_str(), - payload.buyer_pubkey.as_str(), - event.id_str(), - ); - if app_order_id != *order_id { - return; - } - matched_requests - .entry(event.id_str().to_owned()) - .or_insert_with(|| ResolvedAppSellerOrderRequest { - request_event: event.clone(), - request_event_id: event.id_str().to_owned(), - request_author_pubkey: event.author_str().to_owned(), - listing_event_id: listing_event_id_from_tags(&event.tags_as_vec()), - payload, - }); -} - -fn listing_event_id_from_tags(tags: &[Vec<String>]) -> Option<String> { - tags.iter().find_map(|tag| { - if tag.first().map(String::as_str) == Some("listing_event") { - tag.get(1) - .map(String::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - } else { - None - } - }) -} - -fn seller_order_inventory_commitments( - order: &SellerOrderDecisionExport, -) -> Result<Vec<AppOrderDecisionInventoryCommitment>, AppSqliteError> { - if order.lines.is_empty() { + }; + let Some(tags) = value.as_array() else { return Err(AppSqliteError::InvalidProjection { - reason: "seller order decision requires order lines", + reason: "signed local event tags must be an array", }); - } + }; - order - .lines - .iter() - .map(|line| { - let bin_id = - line.listing_bin_id - .as_deref() - .ok_or(AppSqliteError::InvalidProjection { - reason: "seller order decision requires listing bin evidence", - })?; - let stock_count = line.stock_count.ok_or(AppSqliteError::InvalidProjection { - reason: "seller order decision requires current product stock", - })?; - let available_quantity = stock_count.checked_sub(line.reserved_quantity).ok_or( - AppSqliteError::InvalidProjection { - reason: "seller order decision inventory is over-reserved", - }, - )?; - if line.quantity > available_quantity { + tags.iter() + .map(|tag| { + let Some(values) = tag.as_array() else { return Err(AppSqliteError::InvalidProjection { - reason: "seller order decision would over-reserve inventory", + reason: "signed local event tag must be an array", }); - } - - Ok(AppOrderDecisionInventoryCommitment { - bin_id: bin_id.to_owned(), - bin_count: line.quantity, - }) - }) - .collect() -} - -fn publish_order_id(value: &str) -> Result<RadrootsOrderId, AppSyncTransportError> { - RadrootsOrderId::parse(value).map_err(|error| AppSyncTransportError::failed(error.to_string())) -} - -fn publish_event_id(value: &str) -> Result<RadrootsEventId, AppSyncTransportError> { - RadrootsEventId::parse(value).map_err(|error| AppSyncTransportError::failed(error.to_string())) -} - -fn publish_pubkey(value: &str) -> Result<RadrootsPublicKey, AppSyncTransportError> { - RadrootsPublicKey::parse(value) - .map_err(|error| AppSyncTransportError::failed(error.to_string())) -} - -fn publish_listing_addr(value: &str) -> Result<RadrootsListingAddress, AppSyncTransportError> { - RadrootsListingAddress::parse(value) - .map_err(|error| AppSyncTransportError::failed(error.to_string())) -} - -fn publish_bin_id(value: &str) -> Result<RadrootsInventoryBinId, AppSyncTransportError> { - RadrootsInventoryBinId::parse(value) - .map_err(|error| AppSyncTransportError::failed(error.to_string())) -} - -fn trade_locator_from_parts( - trade_order_id: &str, - request_event_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, -) -> Result<RadrootsTradeLocator, AppSyncTransportError> { - Ok( - RadrootsTradeLocator::from_order_id(publish_order_id(trade_order_id)?) - .with_root_event_id(publish_event_id(request_event_id)?) - .with_listing_addr(publish_listing_addr(listing_addr)?) - .with_buyer_pubkey(publish_pubkey(buyer_pubkey)?) - .with_seller_pubkey(publish_pubkey(seller_pubkey)?), - ) -} - -fn trade_locator_from_decision_payload( - payload: &AppOrderDecisionPublishPayload, -) -> Result<RadrootsTradeLocator, AppSyncTransportError> { - trade_locator_from_parts( - payload.trade_order_id.as_str(), - payload.request_event_id.as_str(), - payload.listing_addr.as_str(), - payload.buyer_pubkey.as_str(), - payload.seller_pubkey.as_str(), - ) -} - -fn trade_locator_from_cancellation_payload( - payload: &AppOrderCancellationPublishPayload, -) -> Result<RadrootsTradeLocator, AppSyncTransportError> { - trade_locator_from_parts( - payload.trade_order_id.as_str(), - payload.request_event_id.as_str(), - payload.listing_addr.as_str(), - payload.buyer_pubkey.as_str(), - payload.seller_pubkey.as_str(), - ) -} - -fn trade_decision_from_publish_payload( - payload: &AppOrderDecisionPublishPayload, -) -> Result<DesktopRuntimeTradeDecision, AppSyncTransportError> { - match &payload.decision { - AppOrderDecisionPayload::Accepted { - inventory_commitments, - } => Ok(DesktopRuntimeTradeDecision::Accept { - inventory_commitments: inventory_commitments + }; + values .iter() - .map(|commitment| { - Ok(RadrootsOrderInventoryCommitment { - bin_id: publish_bin_id(commitment.bin_id.as_str())?, - bin_count: commitment.bin_count, - }) + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or(AppSqliteError::InvalidProjection { + reason: "signed local event tag values must be strings", + }) }) - .collect::<Result<Vec<_>, AppSyncTransportError>>()?, - }), - AppOrderDecisionPayload::Declined { reason } => Ok(DesktopRuntimeTradeDecision::Decline { - reason: reason.clone(), - }), - } + .collect() + }) + .collect() } #[cfg(test)] @@ -9103,7 +8823,7 @@ fn farm_sync_payload( .to_string() } -fn signed_order_request_evidence_record_is_usable(record: &RuntimeStoreRecord) -> bool { +fn signed_event_evidence_record_is_usable(record: &RuntimeStoreRecord) -> bool { if record.status != RuntimeStoreRecordStatus::Published || matches!( record.outbox_status, @@ -9135,25 +8855,22 @@ mod tests { use chrono::{Duration, Utc}; use futures_util::{SinkExt, StreamExt}; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, - }; use radroots_event::ids::{ - RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, - RadrootsOrderQuoteId, RadrootsPublicKey, - }; - use radroots_event::order::{ - RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, - RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, - RadrootsOrderItem, RadrootsOrderPricingBasis, RadrootsOrderRequest, + RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, + RadrootsPublicKey, RadrootsTradeId, }; use radroots_event::{ - RadrootsEventEnvelope, RadrootsEventEnvelopeParts, RadrootsEventPtr, + RadrootsEventEnvelope, RadrootsEventEnvelopeParts, + trade::{ + RADROOTS_TRADE_CANCELLATION_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, + RadrootsFulfillmentProfileV1, RadrootsTradeCancellationProfileV1, + RadrootsTradeCandidateLineV1, RadrootsTradeCandidateTermsV1, RadrootsTradeDecisionV1, + RadrootsTradeEconomicsProfileV1, RadrootsTradeMutationBodyV1, + RadrootsTradeMutationEnvelopeV1, canonical_trade_mutation_content, + }, wire::RadrootsNip01EventWireParts as WireEventParts, }; - use radroots_event_codec::order::{ - order_cancellation_event_build, order_decision_event_build, order_request_event_build, - }; + use radroots_event_codec::trade::{trade_mutation_event_build, trade_mutation_from_event}; use radroots_identity::{RadrootsIdentity, RadrootsIdentityId}; use radroots_nostr::prelude::{ RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrKeys, RadrootsNostrSecretKey, @@ -9170,16 +8887,15 @@ mod tests { RuntimeStoreRecordStatus, SourceRuntime, }; use radroots_sdk::{ - LISTING_PUBLISH_OPERATION_KIND, TRADE_CANCELLATION_OPERATION_KIND, - TRADE_DECISION_OPERATION_KIND, TRADE_SUBMIT_OPERATION_KIND, + LISTING_PUBLISH_OPERATION_KIND, TRADE_CANCEL_OPERATION_KIND, + TRADE_DECIDE_CANDIDATE_OPERATION_KIND, TRADE_SUBMIT_PROPOSAL_OPERATION_KIND, }; use radroots_sql_core::{SqlExecutor, SqlxSqliteExecutor}; use radroots_studio_app_core::{ AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePlatform, AppSharedAccountsPaths, DesktopRuntimeLifecycleState, DesktopRuntimeProjectionLifecycleState, DesktopRuntimePublicFarmLocation, - DesktopRuntimeSnapshot, DesktopRuntimeTradeDecision, SHARED_ACCOUNTS_STORE_FILE_NAME, - SHARED_IDENTITY_FILE_NAME, + DesktopRuntimeSnapshot, SHARED_ACCOUNTS_STORE_FILE_NAME, SHARED_IDENTITY_FILE_NAME, }; use radroots_studio_app_remote_signer::{ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, @@ -9187,7 +8903,7 @@ mod tests { use radroots_studio_app_sqlite::{ AppSqliteError, AppSqliteStore, BuyerOrderCoordinationState, DatabaseTarget, DesktopRuntimeWorkflowReceiptSourceKind, DesktopRuntimeWorkflowReceiptState, - latest_schema_version, projected_order_id_from_trade_request, + latest_schema_version, }; use radroots_studio_app_state::{ APP_STATE_FILE_NAME, AppStateCommand, AppStatePersistenceRepository, AppStateRepository, @@ -9195,15 +8911,14 @@ mod tests { HomeRoute, }; use radroots_studio_app_sync::{ - AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, - AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, - AppOrderDecisionPublishPayload, AppOrderRequestItemPayload, AppOrderRequestPublishPayload, - AppPublishContext, AppPublishPayload, AppPublishedOperationReceipt, - AppRelayIngestScopeFreshness, AppRelayIngestScopeStatus, AppSyncRequest, AppSyncResult, - AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, PendingSyncOperation, - PendingSyncOperationState, RecordedAppSyncTransport, SyncAggregateRef, SyncCheckpointState, - SyncCheckpointStatus, SyncConflict, SyncConflictKind, SyncConflictResolutionStatus, - SyncConflictSeverity, SyncOperationKind, SyncTrigger, + AppFarmProfilePublishPayload, AppListingPublishPayload, AppPublishContext, + AppPublishPayload, AppPublishedOperationReceipt, AppRelayIngestScopeFreshness, + AppRelayIngestScopeStatus, AppSyncRequest, AppSyncResult, AppSyncRunStatus, + AppSyncTransport, AppSyncTransportError, AppTradeCancellationPublishPayload, + PendingSyncOperation, PendingSyncOperationState, RecordedAppSyncTransport, + SyncAggregateRef, SyncCheckpointState, SyncCheckpointStatus, SyncConflict, + SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, SyncOperationKind, + SyncTrigger, }; use radroots_studio_app_view::{ AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, @@ -9249,7 +8964,6 @@ mod tests { DesktopRuntimeSupervisorDiagnosticsState, SYNC_TRANSPORT_UNAVAILABLE_MESSAGE, TokioRuntimeBuilder, default_sync_transport, direct_relay_event_source_runtime, farm_sync_payload, is_hex_64, pending_sync_upsert, signed_event_from_local_record, - trade_decision_from_publish_payload, }; use crate::pack_day_host_handoff::PackDayHostHandoffError; use crate::pack_day_print::{ @@ -9527,17 +9241,20 @@ mod tests { })); } - fn seed_relay_with_order_request( + fn seed_relay_with_trade_proposal( runtime: &DesktopAppRuntime, relay: &ThreadedAckRelay, order_id: OrderId, ) { - let request_event = runtime + let proposal_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); + .resolve_order_lifecycle_evidence(order_id) + .expect("trade proposal evidence should resolve") + .root; + let parts = + trade_mutation_event_build(proposal_event).expect("trade proposal event should build"); + let event = signed_test_event(SDK_TEST_BUYER_SECRET_KEY_HEX, 1_774_000_010, parts); + seed_relay_with_sdk_event(relay, &event); } fn seed_relay_with_shared_event_id( @@ -9667,14 +9384,6 @@ mod tests { signed_event_id(format!("app:signed_event:listing:{label}").as_str()) } - fn test_order_id(value: &str) -> RadrootsOrderId { - RadrootsOrderId::parse(value).expect("order id") - } - - fn test_quote_id(value: &str) -> RadrootsOrderQuoteId { - RadrootsOrderQuoteId::parse(value).expect("quote id") - } - fn test_bin_id(value: &str) -> RadrootsInventoryBinId { RadrootsInventoryBinId::parse(value).expect("bin id") } @@ -9683,37 +9392,134 @@ mod tests { RadrootsPublicKey::parse(value).expect("pubkey") } - fn test_listing_addr(value: &str) -> RadrootsListingAddress { - RadrootsListingAddress::parse(value).expect("listing address") + fn test_trade_order_id(order_id: OrderId) -> String { + order_id.to_string().replace('-', "") } - fn test_order_decision_publish_payload_to_event_payload( - payload: &AppOrderDecisionPublishPayload, - ) -> RadrootsOrderDecision { - RadrootsOrderDecision { - order_id: test_order_id(payload.trade_order_id.as_str()), - listing_addr: test_listing_addr(payload.listing_addr.as_str()), - buyer_pubkey: test_pubkey(payload.buyer_pubkey.as_str()), - seller_pubkey: test_pubkey(payload.seller_pubkey.as_str()), - decision: match &payload.decision { - AppOrderDecisionPayload::Accepted { - inventory_commitments, - } => RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: inventory_commitments - .iter() - .map(|commitment| RadrootsOrderInventoryCommitment { - bin_id: test_bin_id(commitment.bin_id.as_str()), - bin_count: commitment.bin_count, - }) - .collect(), + fn test_trade_proposal_envelope( + trade_order_id: &str, + listing_addr: &str, + listing_event_id: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + order_quantity: u32, + ) -> RadrootsTradeMutationEnvelopeV1 { + let quantity = order_quantity.to_string(); + let total_minor_units = 800_u32 + .checked_mul(order_quantity) + .expect("test trade total should fit") + .to_string(); + canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: super::RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_owned(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: RadrootsTradeId::parse(trade_order_id).expect("trade id"), + root_mutation_id: None, + buyer_pubkey: test_pubkey(buyer_pubkey), + seller_pubkey: test_pubkey(seller_pubkey), + farm_id: RadrootsDTag::parse("seller-decision-farm").expect("farm d tag"), + parent_mutation_ids: Vec::new(), + author_pubkey: test_pubkey(buyer_pubkey), + counterparty_pubkey: test_pubkey(seller_pubkey), + authored_at_unix_s: 1_774_000_010, + body: RadrootsTradeMutationBodyV1::Proposal { + candidate: RadrootsTradeCandidateTermsV1 { + candidate_id: None, + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + base_candidate_id: None, + supersession_intent: None, + buyer_pubkey: test_pubkey(buyer_pubkey), + seller_pubkey: test_pubkey(seller_pubkey), + farm_id: RadrootsDTag::parse("seller-decision-farm").expect("farm d tag"), + lines: vec![RadrootsTradeCandidateLineV1 { + line_id: RadrootsDTag::parse("seller-order-primary-line") + .expect("line d tag"), + listing_addr: RadrootsAddressableCoordinate::parse(listing_addr) + .expect("listing address"), + listing_event_id: test_event_id(listing_event_id), + listing_snapshot_sha256: test_event_id_seed(listing_event_id), + product_id: "seller-order-product".to_owned(), + option_id: None, + bin_id: test_bin_id("seller-order-primary-bin"), + quantity_mantissa: quantity, + quantity_scale: 0, + unit_code: "each".to_owned(), + unit_profile: "app-each".to_owned(), + unit_price_mantissa: "800".to_owned(), + currency_code: "USD".to_owned(), + line_subtotal_mantissa: total_minor_units.clone(), + replaces_line_id: None, + }], + line_tombstones: Vec::new(), + economics: RadrootsTradeEconomicsProfileV1 { + profile_id: "seller-decision-fixed".to_owned(), + currency_code: "USD".to_owned(), + currency_exponent: 2, + rounding_profile: "minor-units".to_owned(), + subtotal_mantissa: total_minor_units.clone(), + discount_total_mantissa: "0".to_owned(), + adjustment_total_mantissa: "0".to_owned(), + total_mantissa: total_minor_units, + adjustments: Vec::new(), + }, + fulfillment: RadrootsFulfillmentProfileV1 { + profile_id: "seller-decision-pickup".to_owned(), + method: "pickup".to_owned(), + starts_at_unix_s: 4_102_444_800, + ends_at_unix_s: 4_102_531_200, + timezone: "UTC".to_owned(), + utc_offset_seconds: 0, + fold: 0, + location_class: "farm".to_owned(), + requires_private_terms: false, + }, + cancellation: RadrootsTradeCancellationProfileV1 { + profile_id: "buyer-pre-agreement".to_owned(), + buyer_pre_agreement: true, + post_agreement_cutoff_unix_s: None, + }, + private_terms: None, + proposal_expires_at_unix_s: 4_102_444_799, }, - AppOrderDecisionPayload::Declined { reason } => { - RadrootsOrderDecisionOutcome::Declined { - reason: reason.clone(), - } - } }, - } + }) + .expect("canonical trade proposal") + .envelope + } + + fn direct_relay_trade_work_payload( + account_id: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + source: &str, + ) -> AppPublishPayload { + let order_id = OrderId::generate(); + let trade_id = RadrootsTradeId::parse(order_id.to_string().replace('-', "").as_str()) + .expect("trade id"); + AppPublishPayload::TradeCancellation(AppTradeCancellationPublishPayload { + context: AppPublishContext::new(account_id.to_owned(), source), + order_id, + farm_id: FarmId::generate(), + envelope: RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: RADROOTS_TRADE_CANCELLATION_CONTRACT_ID.to_owned(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id, + root_mutation_id: None, + buyer_pubkey: test_pubkey(buyer_pubkey), + seller_pubkey: test_pubkey(seller_pubkey), + farm_id: RadrootsDTag::parse("direct-relay-test").expect("farm d tag"), + parent_mutation_ids: Vec::new(), + author_pubkey: test_pubkey(buyer_pubkey), + counterparty_pubkey: test_pubkey(seller_pubkey), + authored_at_unix_s: 1_774_000_020, + body: RadrootsTradeMutationBodyV1::Cancellation { + target_candidate_id: None, + target_claim_mutation_id: None, + reason: "direct relay transport guard".to_owned(), + }, + }, + }) } fn install_recorded_sync_transport( @@ -9817,7 +9623,7 @@ mod tests { } #[test] - fn runtime_direct_relay_transport_rejects_typed_order_request_work() { + fn runtime_direct_relay_transport_rejects_typed_trade_proposal_work() { let relay = ThreadedAckRelay::spawn(); let manager = RadrootsNostrAccountsManager::new_in_memory(); let account_id = manager @@ -9828,71 +9634,14 @@ mod tests { .expect("buyer signer lookup should succeed") .expect("buyer account should have local signer"); let seller_identity = RadrootsIdentity::generate(); - let product_id = ProductId::generate(); - let order_id = OrderId::generate(); - let listing_event_id = "1".repeat(64); - let listing_addr = format!( - "30402:{}:{}", - seller_identity.public_key_hex(), - super::d_tag_from_uuid(ProductId::generate().as_uuid()) - ); - let order_document = RadrootsOrderRequest { - order_id: test_order_id(order_id.to_string().as_str()), - listing_addr: test_listing_addr(listing_addr.as_str()), - buyer_pubkey: test_pubkey(buyer_identity.public_key_hex().as_str()), - seller_pubkey: test_pubkey(seller_identity.public_key_hex().as_str()), - items: vec![RadrootsOrderItem { - bin_id: test_bin_id("bin-1"), - bin_count: 1, - }], - economics: RadrootsOrderEconomics { - quote_id: test_quote_id(format!("quote-{order_id}").as_str()), - quote_version: 1, - pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency: RadrootsCoreCurrency::USD, - items: vec![RadrootsOrderEconomicItem { - bin_id: test_bin_id("bin-1"), - bin_count: 1, - quantity_amount: RadrootsCoreDecimal::from(1u32), - quantity_unit: RadrootsCoreUnit::Each, - unit_price_amount: RadrootsCoreDecimal::from(5u32), - unit_price_currency: RadrootsCoreCurrency::USD, - line_subtotal: RadrootsCoreMoney::from_minor_units_u32( - 500, - RadrootsCoreCurrency::USD, - ), - }], - discounts: Vec::new(), - adjustments: Vec::new(), - subtotal: RadrootsCoreMoney::from_minor_units_u32(500, RadrootsCoreCurrency::USD), - discount_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD), - adjustment_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD), - total: RadrootsCoreMoney::from_minor_units_u32(500, RadrootsCoreCurrency::USD), - }, - }; - let payload = AppPublishPayload::OrderRequest(AppOrderRequestPublishPayload { - context: AppPublishContext::new(account_id.to_string(), "place_personal_order") - .with_source_local_event_id("app:local_work:order_request:direct"), - order_id, - farm_id: FarmId::generate(), - status: Some("needs_action".to_owned()), - order_document_json: Some(json!({"document": {"order": order_document}})), - listing_addr: Some(listing_addr), - listing_event_id: Some(listing_event_id), - listing_relays: vec![relay.url().to_owned()], - buyer_pubkey: Some(buyer_identity.public_key_hex()), - seller_pubkey: Some(seller_identity.public_key_hex()), - items: vec![AppOrderRequestItemPayload { - product_id, - quantity: 1, - }], - currency_code: Some("USD".to_owned()), - total_minor_units: Some(500), - note: Some("coordinate pickup".to_owned()), - confirm_public_note: true, - }); + let payload = direct_relay_trade_work_payload( + account_id.to_string().as_str(), + buyer_identity.public_key_hex().as_str(), + seller_identity.public_key_hex().as_str(), + "trade_proposal", + ); let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") - .expect("typed order request publish work should serialize"); + .expect("typed trade proposal publish work should serialize"); let mut transport = ConfiguredRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); @@ -9903,14 +9652,14 @@ mod tests { pending_operations: vec![operation], known_conflicts: Vec::new(), }) - .expect_err("direct relay order request publish should use DesktopRuntimeSupervisor"); + .expect_err("direct relay trade proposal publish should use DesktopRuntimeSupervisor"); assert_publish_payload_uses_desktop_runtime_supervisor(error); assert_eq!(relay.event_count(), 0); } #[test] - fn runtime_direct_relay_transport_rejects_typed_order_decision_work() { + fn runtime_direct_relay_transport_rejects_typed_trade_decision_work() { let relay = ThreadedAckRelay::spawn(); let manager = RadrootsNostrAccountsManager::new_in_memory(); let account_id = manager @@ -9921,26 +9670,14 @@ mod tests { .expect("seller signer lookup should succeed") .expect("seller account should have local signer"); let buyer_pubkey = "1111111111111111111111111111111111111111111111111111111111111111"; - let payload = AppPublishPayload::OrderDecision(AppOrderDecisionPublishPayload { - context: AppPublishContext::new(account_id.to_string(), "seller_order_decision"), - app_order_id: OrderId::generate(), - farm_id: FarmId::generate(), - trade_order_id: "order-1".to_owned(), - request_event_id: test_event_id_seed("order-request-event-1"), - listing_event_id: Some(test_event_id_seed("listing-event-1")), - listing_addr: format!("30402:{}:listing-key", identity.public_key_hex()), - buyer_pubkey: buyer_pubkey.to_owned(), - seller_pubkey: identity.public_key_hex(), - decision: AppOrderDecisionPayload::Accepted { - inventory_commitments: vec![AppOrderDecisionInventoryCommitment { - bin_id: "bin-1".to_owned(), - bin_count: 2, - }], - }, - confirm_public_note: false, - }); + let payload = direct_relay_trade_work_payload( + account_id.to_string().as_str(), + buyer_pubkey, + identity.public_key_hex().as_str(), + "trade_candidate_decision", + ); let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") - .expect("typed order decision publish work should serialize"); + .expect("typed trade decision publish work should serialize"); let mut transport = ConfiguredRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); @@ -9951,14 +9688,14 @@ mod tests { pending_operations: vec![operation], known_conflicts: Vec::new(), }) - .expect_err("direct relay order decision publish should use DesktopRuntimeSupervisor"); + .expect_err("direct relay trade decision publish should use DesktopRuntimeSupervisor"); assert_publish_payload_uses_desktop_runtime_supervisor(error); assert_eq!(relay.event_count(), 0); } #[test] - fn runtime_direct_relay_transport_rejects_typed_order_lifecycle_work() { + fn runtime_direct_relay_transport_rejects_typed_trade_lifecycle_work() { let relay = ThreadedAckRelay::spawn(); let manager = RadrootsNostrAccountsManager::new_in_memory(); let buyer_account_id = manager @@ -9975,42 +9712,17 @@ mod tests { .get_signing_identity(&seller_account_id) .expect("seller signer lookup should succeed") .expect("seller account should have local signer"); - let app_order_id = OrderId::generate(); - let farm_id = FarmId::generate(); - let listing_addr = format!( - "30402:{}:AAAAAAAAAAAAAAAAAAAAAg", - seller_identity.public_key_hex() + let cancellation = direct_relay_trade_work_payload( + buyer_account_id.to_string().as_str(), + buyer_identity.public_key_hex().as_str(), + seller_identity.public_key_hex().as_str(), + "trade_cancellation", ); - let common = ( - app_order_id, - farm_id, - "order-1".to_owned(), - test_event_id_seed("order-request-event-1"), - listing_addr, - buyer_identity.public_key_hex(), - seller_identity.public_key_hex(), - ); - let cancellation = - AppPublishPayload::OrderCancellation(AppOrderCancellationPublishPayload { - context: AppPublishContext::new( - buyer_account_id.to_string(), - "buyer_order_cancellation", - ), - app_order_id: common.0, - farm_id: common.1, - trade_order_id: common.2.clone(), - request_event_id: common.3.clone(), - listing_addr: common.4.clone(), - buyer_pubkey: common.5.clone(), - seller_pubkey: common.6.clone(), - reason: "buyer cancelled order".to_owned(), - confirm_public_note: false, - }); let operations = [cancellation] .into_iter() .map(|payload| { PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") - .expect("typed lifecycle publish work should serialize") + .expect("typed trade lifecycle publish work should serialize") }) .collect::<Vec<_>>(); let mut transport = @@ -10023,7 +9735,7 @@ mod tests { pending_operations: operations, known_conflicts: Vec::new(), }) - .expect_err("direct relay lifecycle publish should use DesktopRuntimeSupervisor"); + .expect_err("direct relay trade lifecycle publish should use DesktopRuntimeSupervisor"); assert_publish_payload_uses_desktop_runtime_supervisor(error); assert_eq!(relay.event_count(), 0); @@ -10530,7 +10242,8 @@ mod tests { } #[test] - fn runtime_direct_relay_transport_rejects_order_request_missing_listing_provenance_target() { + fn runtime_direct_relay_transport_rejects_trade_work_before_transport_level_provenance_lookup() + { let relay = ThreadedAckRelay::spawn(); let manager = RadrootsNostrAccountsManager::new_in_memory(); let account_id = manager @@ -10541,28 +10254,14 @@ mod tests { .expect("buyer signer lookup should succeed") .expect("buyer account should have local signer"); let seller_pubkey = "2222222222222222222222222222222222222222222222222222222222222222"; - let payload = AppPublishPayload::OrderRequest(AppOrderRequestPublishPayload { - context: AppPublishContext::new(account_id.to_string(), "order_missing_listing_relay"), - order_id: OrderId::generate(), - farm_id: FarmId::generate(), - status: Some("needs_action".to_owned()), - order_document_json: Some(json!({"document": {"order": {}}})), - listing_addr: Some(format!("30402:{seller_pubkey}:listing-key")), - listing_event_id: Some("listing-event-id".to_owned()), - listing_relays: vec!["wss://listing.example".to_owned()], - buyer_pubkey: Some(identity.public_key_hex()), - seller_pubkey: Some(seller_pubkey.to_owned()), - items: vec![AppOrderRequestItemPayload { - product_id: ProductId::generate(), - quantity: 1, - }], - currency_code: Some("USD".to_owned()), - total_minor_units: Some(450), - note: None, - confirm_public_note: false, - }); + let payload = direct_relay_trade_work_payload( + account_id.to_string().as_str(), + identity.public_key_hex().as_str(), + seller_pubkey, + "trade_proposal", + ); let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-25T07:00:00Z") - .expect("order publish payload should serialize"); + .expect("trade publish payload should serialize"); let mut transport = ConfiguredRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); @@ -10573,7 +10272,7 @@ mod tests { pending_operations: vec![operation], known_conflicts: Vec::new(), }) - .expect_err("direct relay order request should use DesktopRuntimeSupervisor"); + .expect_err("direct relay trade work should use DesktopRuntimeSupervisor"); assert_publish_payload_uses_desktop_runtime_supervisor(error); assert_eq!(relay.event_count(), 0); @@ -14517,7 +14216,7 @@ mod tests { .expect("buyer order detail") .order_note .as_deref(), - Some("Leave by the cooler") + None ); } @@ -14656,30 +14355,22 @@ mod tests { let payload = runtime .prepare_order_accept(order_id) .expect("seller order accept payload should prepare"); - let decision = trade_decision_from_publish_payload(&payload) - .expect("order accept payload should convert to SDK trade decision"); - assert_eq!(payload.app_order_id, order_id); - assert_eq!(payload.trade_order_id, "seller-order-decision-1"); - assert_eq!( - payload.request_event_id, - shared_order_request_event_id(&paths, "seller-order-decision-1") - ); + assert_eq!(payload.order_id, order_id); + assert_eq!(payload.envelope.buyer_pubkey.as_str(), buyer_pubkey); + assert_eq!(payload.envelope.seller_pubkey.as_str(), seller_pubkey); + assert_eq!(payload.envelope.author_pubkey.as_str(), seller_pubkey); + assert_eq!(payload.envelope.counterparty_pubkey.as_str(), buyer_pubkey); + assert!(!payload.acknowledge_private_terms); + let RadrootsTradeMutationBodyV1::Decision { decision, .. } = &payload.envelope.body else { + panic!("expected trade candidate decision body"); + }; assert_eq!( - payload.listing_event_id, - Some(signed_listing_event_id("seller-order-decision")) + decision, + &RadrootsTradeDecisionV1::Accepted { + reservation_assertion: None + } ); - assert_eq!(payload.buyer_pubkey, buyer_pubkey); - assert_eq!(payload.seller_pubkey, seller_pubkey); - let DesktopRuntimeTradeDecision::Accept { - inventory_commitments, - } = decision - else { - panic!("expected accepted decision"); - }; - assert_eq!(inventory_commitments.len(), 1); - assert_eq!(inventory_commitments[0].bin_id, "seller-order-primary-bin"); - assert_eq!(inventory_commitments[0].bin_count, 2); cleanup_bootstrapped_runtime_paths(&paths); } @@ -14694,22 +14385,20 @@ mod tests { let payload = runtime .prepare_order_decline(order_id, " out of stock ", true) .expect("seller order decline payload should prepare"); - let decision = trade_decision_from_publish_payload(&payload) - .expect("order decline payload should convert to SDK trade decision"); - assert_eq!(payload.buyer_pubkey, buyer_pubkey); - assert_eq!(payload.seller_pubkey, seller_pubkey); + assert_eq!(payload.order_id, order_id); + assert_eq!(payload.envelope.buyer_pubkey.as_str(), buyer_pubkey); + assert_eq!(payload.envelope.seller_pubkey.as_str(), seller_pubkey); + assert!(!payload.acknowledge_private_terms); + let RadrootsTradeMutationBodyV1::Decision { decision, .. } = &payload.envelope.body else { + panic!("expected trade candidate decision body"); + }; assert_eq!( - payload.decision, - AppOrderDecisionPayload::Declined { + decision, + &RadrootsTradeDecisionV1::Declined { reason: "out of stock".to_owned() } ); - assert!(payload.confirm_public_note); - let DesktopRuntimeTradeDecision::Decline { reason } = decision else { - panic!("expected declined decision"); - }; - assert_eq!(reason, "out of stock"); cleanup_bootstrapped_runtime_paths(&paths); } @@ -14726,11 +14415,11 @@ mod tests { .prepare_order_accept(order_id) .expect("seller order accept payload should prepare from older evidence"); - assert_eq!(payload.trade_order_id, "seller-order-decision-1"); - assert_eq!( - payload.request_event_id, - shared_order_request_event_id(&paths, "seller-order-decision-1") - ); + assert_eq!(payload.order_id, order_id); + assert!(matches!( + payload.envelope.body, + RadrootsTradeMutationBodyV1::Decision { .. } + )); cleanup_bootstrapped_runtime_paths(&paths); } @@ -14750,7 +14439,7 @@ mod tests { assert!(matches!( error, AppSqliteError::InvalidProjection { - reason: "seller order decision requires signed order request evidence" + reason: "trade lifecycle requires signed proposal evidence" } )); @@ -14785,7 +14474,7 @@ mod tests { assert_eq!(persisted_order_status(&runtime, order_id), "needs_action"); assert_eq!( persisted_order_workflow_agreement(&runtime, order_id), - "agreed_pending_validation" + "contested" ); assert_eq!(relay.event_count(), 1); @@ -14858,7 +14547,7 @@ mod tests { 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); + seed_relay_with_trade_proposal(&runtime, &relay, order_id); assert!( runtime @@ -14889,7 +14578,7 @@ mod tests { 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); + seed_relay_with_trade_proposal(&runtime, &relay, order_id); assert!( runtime @@ -15109,13 +14798,13 @@ mod tests { payload["document"]["order"]["economics"]["total"]["amount"], "16.00" ); - assert_eq!( - payload["app_order"]["buyer_order_note"], - "Leave by the cooler" - ); - assert_eq!( - payload["app_order"]["buyer_order_note_public_confirmed"], - true + assert!(payload["app_order"].get("buyer_email").is_none()); + assert!(payload["app_order"].get("buyer_phone").is_none()); + assert!(payload["app_order"].get("buyer_order_note").is_none()); + assert!( + payload["app_order"] + .get("buyer_order_note_public_confirmed") + .is_none() ); assert_eq!( payload["app_order"]["lines"][0]["listing_bin_id"], @@ -15476,10 +15165,7 @@ mod tests { assert_eq!(row.status, BuyerOrderStatus::Placed); assert_eq!(detail.order_id, fixture.order_id); assert_eq!(detail.status, BuyerOrderStatus::Placed); - assert_eq!( - detail.workflow.agreement, - TradeAgreementStatus::AgreedPendingValidation - ); + assert_eq!(detail.workflow.agreement, TradeAgreementStatus::Contested); assert_eq!( detail.workflow.provenance.last_event_id.as_deref(), Some(fixture.decision_event_id.as_str()) @@ -18975,7 +18661,8 @@ mod tests { let listing_key = super::d_tag_from_uuid(ProductId::generate().as_uuid()); let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); let listing_event_id = signed_listing_event_id(label); - let trade_order_id = format!("{label}-trade-order"); + let order_id = OrderId::generate(); + let trade_order_id = test_trade_order_id(order_id); append_app_signed_listing_record( &paths, "linked-seller-account", @@ -18994,11 +18681,6 @@ mod tests { seller_pubkey, 2, ); - let order_id = projected_order_id_from_trade_request( - trade_order_id.as_str(), - buyer_pubkey.as_str(), - request_event_id.as_str(), - ); let decision_event_id = if append_decision { append_signed_order_decision_record( &paths, @@ -19052,7 +18734,8 @@ mod tests { .public_key_hex; let buyer_pubkey = SDK_TEST_BUYER_PUBLIC_KEY_HEX.to_owned(); let product_id = ProductId::generate(); - let trade_order_id = "seller-order-decision-1"; + let order_id = OrderId::generate(); + let trade_order_id = test_trade_order_id(order_id); 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}"); @@ -19066,20 +18749,15 @@ mod tests { listing_event_id.as_str(), stock_count, ); - let request_event_id = append_signed_order_request_record( + let _request_event_id = append_signed_order_request_record( &paths, - trade_order_id, + trade_order_id.as_str(), listing_addr.as_str(), listing_event_id.as_str(), buyer_pubkey.as_str(), seller_pubkey.as_str(), order_quantity, ); - let order_id = projected_order_id_from_trade_request( - trade_order_id, - buyer_pubkey.as_str(), - request_event_id.as_str(), - ); ( runtime, @@ -19118,7 +18796,8 @@ mod tests { .public_key_hex; let buyer_pubkey = SDK_TEST_BUYER_PUBLIC_KEY_HEX.to_owned(); let product_id = ProductId::generate(); - let trade_order_id = "seller-order-decision-1"; + let order_id = OrderId::generate(); + let trade_order_id = test_trade_order_id(order_id); 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}"); @@ -19132,20 +18811,15 @@ mod tests { listing_event_id.as_str(), stock_count, ); - let request_event_id = append_verified_signed_order_request_record( + let _request_event_id = append_verified_signed_order_request_record( &paths, - trade_order_id, + trade_order_id.as_str(), listing_addr.as_str(), listing_event_id.as_str(), buyer_pubkey.as_str(), seller_pubkey.as_str(), order_quantity, ); - let order_id = projected_order_id_from_trade_request( - trade_order_id, - buyer_pubkey.as_str(), - request_event_id.as_str(), - ); ( runtime, @@ -19161,11 +18835,11 @@ mod tests { runtime: &DesktopAppRuntime, relay: &ThreadedAckRelay, order_id: OrderId, - product_id: ProductId, - seller_pubkey: &str, - buyer_pubkey: &str, + _product_id: ProductId, + _seller_pubkey: &str, + _buyer_pubkey: &str, ) { - let (account_id, farm_id, accounts_manager) = { + let (account_id, accounts_manager, proposal) = { let state = runtime.lock_state(); let selected_account = state .state_store @@ -19175,62 +18849,35 @@ mod tests { .expect("selected seller account"); ( selected_account.account.account_id.clone(), - state.selected_farm_id().expect("selected farm"), state .accounts_manager .as_ref() .expect("accounts manager") .clone(), + state + .resolve_order_lifecycle_evidence(order_id) + .expect("seller trade proposal evidence should resolve") + .root, ) }; - let listing_key = super::d_tag_from_uuid(product_id.as_uuid()); - let request_event_id = runtime - .lock_state() - .resolve_seller_order_request_evidence(order_id) - .expect("seller request evidence should resolve") - .request_event - .id_str() - .to_owned(); - let payload = AppPublishPayload::OrderDecision(AppOrderDecisionPublishPayload { - context: AppPublishContext::new(account_id.clone(), "seller_order_decision"), - app_order_id: order_id, - farm_id, - trade_order_id: "seller-order-decision-1".to_owned(), - request_event_id, - listing_event_id: Some(signed_listing_event_id("seller-order-decision")), - listing_addr: format!("30402:{seller_pubkey}:{listing_key}"), - buyer_pubkey: buyer_pubkey.to_owned(), - seller_pubkey: seller_pubkey.to_owned(), - decision: AppOrderDecisionPayload::Accepted { - inventory_commitments: vec![AppOrderDecisionInventoryCommitment { - bin_id: "seller-order-primary-bin".to_owned(), - bin_count: 2, - }], - }, - confirm_public_note: false, - }); - let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") - .expect("prior order decision publish work should serialize"); - let payload = operation - .publish_payload() - .expect("prior order decision operation should carry payload"); - let AppPublishPayload::OrderDecision(payload) = payload else { - panic!("prior order decision operation should carry order decision payload") - }; let account_id = RadrootsIdentityId::parse(account_id.as_str()).expect("selected account id"); let identity = accounts_manager .get_signing_identity(&account_id) .expect("seller signer lookup should succeed") .expect("seller account should have local signer"); - let request_event_id = test_event_id(payload.request_event_id.as_str()); - let decision = test_order_decision_publish_payload_to_event_payload(&payload); - let parts = order_decision_event_build(&request_event_id, &request_event_id, &decision) - .expect("order decision draft should build"); + let envelope = super::trade_decision_envelope_from_proposal( + &proposal, + &super::AppSellerOrderDecisionCommand::Accept, + 1_774_000_030, + ) + .expect("trade decision envelope should build"); + let parts = trade_mutation_event_build(envelope) + .expect("trade decision event builder should build"); let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("order decision event builder should build") + .expect("trade decision event builder should build") .sign_with_keys(identity.keys()) - .expect("order decision event should sign"); + .expect("trade decision event should sign"); publish_signed_test_event_to_relay(relay, &event); assert_eq!(relay.event_count(), 1); } @@ -19360,25 +19007,16 @@ mod tests { .expect("open shared runtime store db"); let store = RuntimeStore::new(executor); store.migrate_up().expect("migrate shared runtime store"); - 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 = order_request_event_build( - &RadrootsEventPtr { - id: test_event_id_seed(listing_event_id), - relays: Some("wss://relay.example".to_owned()), - }, - &order, - ) - .expect("order request draft should build"); + let envelope = test_trade_proposal_envelope( + trade_order_id, + listing_addr, + listing_event_id, + buyer_pubkey, + seller_pubkey, + order_quantity, + ); + let parts = + trade_mutation_event_build(envelope).expect("trade proposal event draft should build"); let record_id = format!("app:signed_event:order-request:{trade_order_id}"); let event_id = signed_event_id(record_id.as_str()); let event = test_event_from_parts( @@ -19433,7 +19071,7 @@ mod tests { relay_set_fingerprint: Some("relay-set".to_owned()), relay_delivery_json: Some(relay_delivery_json), }) - .expect("append signed order request"); + .expect("append signed trade proposal"); stored_event_id } @@ -19457,33 +19095,24 @@ mod tests { .expect("open shared runtime store db"); let store = RuntimeStore::new(executor); store.migrate_up().expect("migrate shared runtime store"); - 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 = order_request_event_build( - &RadrootsEventPtr { - id: test_event_id_seed(listing_event_id), - relays: Some("wss://relay.example".to_owned()), - }, - &order, - ) - .expect("order request draft should build"); + let envelope = test_trade_proposal_envelope( + trade_order_id, + listing_addr, + listing_event_id, + buyer_pubkey, + seller_pubkey, + order_quantity, + ); + let parts = + trade_mutation_event_build(envelope).expect("trade proposal event draft should build"); 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") + .expect("trade proposal event should build") .custom_created_at(RadrootsNostrTimestamp::from_secs(1_774_000_010)) .sign_with_keys(&keys) - .expect("order request event should sign"); + .expect("trade proposal event should sign"); let event = radroots_event_from_nostr(&signed_event); let stored_event_id = event.id_str().to_owned(); let event_created_at = i64::try_from(event.created_at_u64()).expect("event timestamp"); @@ -19531,68 +19160,29 @@ mod tests { relay_set_fingerprint: Some("relay-set".to_owned()), relay_delivery_json: Some(relay_delivery_json), }) - .expect("append verified signed order request"); + .expect("append verified signed trade proposal"); stored_event_id } - fn signed_order_request_economics( - trade_order_id: &str, - order_quantity: u32, - ) -> RadrootsOrderEconomics { - let currency = RadrootsCoreCurrency::USD; - let unit_price_minor_units = 800_u32; - let total_minor_units = unit_price_minor_units - .checked_mul(order_quantity) - .expect("order total should fit"); - - RadrootsOrderEconomics { - quote_id: test_quote_id(format!("{trade_order_id}-quote").as_str()), - quote_version: 1, - pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency, - items: vec![RadrootsOrderEconomicItem { - bin_id: test_bin_id("seller-order-primary-bin"), - bin_count: order_quantity, - quantity_amount: RadrootsCoreDecimal::from(1u32), - quantity_unit: RadrootsCoreUnit::Each, - unit_price_amount: RadrootsCoreDecimal::from(8u32), - unit_price_currency: currency, - line_subtotal: RadrootsCoreMoney::from_minor_units_u32(total_minor_units, currency), - }], - discounts: Vec::new(), - adjustments: Vec::new(), - subtotal: RadrootsCoreMoney::from_minor_units_u32(total_minor_units, currency), - discount_total: RadrootsCoreMoney::zero(currency), - adjustment_total: RadrootsCoreMoney::zero(currency), - total: RadrootsCoreMoney::from_minor_units_u32(total_minor_units, currency), - } - } - fn append_signed_order_decision_record( paths: &AppDesktopRuntimePaths, - trade_order_id: &str, + _trade_order_id: &str, request_event_id: &str, listing_addr: &str, - buyer_pubkey: &str, + _buyer_pubkey: &str, seller_pubkey: &str, - order_quantity: u32, + _order_quantity: u32, ) -> String { - let payload = RadrootsOrderDecision { - 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), - decision: RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_bin_id("seller-order-primary-bin"), - bin_count: order_quantity, - }], - }, - }; - let request_event_id = test_event_id(request_event_id); - let parts = order_decision_event_build(&request_event_id, &request_event_id, &payload) - .expect("order decision draft should build"); - let record_id = format!("app:signed_event:order-decision:{trade_order_id}"); + let proposal = shared_trade_proposal_envelope_by_event_id(paths, request_event_id); + let envelope = super::trade_decision_envelope_from_proposal( + &proposal, + &super::AppSellerOrderDecisionCommand::Accept, + 1_774_000_030, + ) + .expect("trade decision envelope should build"); + let parts = + trade_mutation_event_build(envelope).expect("trade decision draft should build"); + let record_id = format!("app:signed_event:trade-decision:{request_event_id}"); append_trade_signed_event_record( paths, record_id.as_str(), @@ -19605,26 +19195,24 @@ mod tests { #[allow(clippy::too_many_arguments)] fn append_signed_order_cancellation_record_with_prev( paths: &AppDesktopRuntimePaths, - trade_order_id: &str, + _trade_order_id: &str, event_key: &str, request_event_id: &str, - prev_event_id: &str, + _prev_event_id: &str, listing_addr: &str, buyer_pubkey: &str, - seller_pubkey: &str, + _seller_pubkey: &str, ) -> String { - let request_event_id = test_event_id(request_event_id); - let prev_event_id = test_event_id(prev_event_id); - let payload = RadrootsOrderCancellation { - 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), - reason: "buyer cancelled order".to_owned(), - }; - let parts = order_cancellation_event_build(&request_event_id, &prev_event_id, &payload) - .expect("order cancellation draft should build"); - let record_id = format!("app:signed_event:cancellation:{event_key}"); + let proposal = shared_trade_proposal_envelope_by_event_id(paths, request_event_id); + let envelope = super::trade_cancellation_envelope_from_proposal( + &proposal, + "buyer cancelled order", + 1_774_000_040, + ) + .expect("trade cancellation envelope should build"); + let parts = + trade_mutation_event_build(envelope).expect("trade cancellation draft should build"); + let record_id = format!("app:signed_event:trade-cancellation:{event_key}"); append_trade_signed_event_record( paths, record_id.as_str(), @@ -19634,6 +19222,18 @@ mod tests { ) } + fn shared_trade_proposal_envelope_by_event_id( + paths: &AppDesktopRuntimePaths, + event_id: &str, + ) -> RadrootsTradeMutationEnvelopeV1 { + shared_runtime_store_records(paths) + .into_iter() + .find(|record| record.event_id.as_deref() == Some(event_id)) + .and_then(|record| signed_event_from_local_record(&record).ok().flatten()) + .and_then(|event| trade_mutation_from_event(&event).ok()) + .expect("shared trade proposal envelope should exist") + } + fn assert_order_lifecycle_evidence_invalid(error: AppSqliteError) { assert!( matches!( @@ -19743,10 +19343,10 @@ mod tests { outbox_status = 'pending', relay_set_fingerprint = NULL, relay_delivery_json = NULL - WHERE record_id = 'app:signed_event:order-request:seller-order-decision-1'", + WHERE record_id LIKE 'app:signed_event:order-request:%'", "[]", ) - .expect("mark shared order request evidence pending"); + .expect("mark shared trade proposal evidence pending"); } fn append_unrelated_signed_event_records(paths: &AppDesktopRuntimePaths, count: usize) { @@ -19945,18 +19545,6 @@ mod tests { .expect("shared local records should list") } - fn shared_order_request_event_id( - paths: &AppDesktopRuntimePaths, - trade_order_id: &str, - ) -> String { - let record_id = format!("app:signed_event:order-request:{trade_order_id}"); - shared_runtime_store_records(paths) - .into_iter() - .find(|record| record.record_id == record_id) - .and_then(|record| record.event_id) - .expect("signed order request event id should exist") - } - fn shared_order_events_by_kind( paths: &AppDesktopRuntimePaths, kind: i64, @@ -20051,7 +19639,10 @@ mod tests { .expect("SDK workflow receipt should load") .expect("SDK workflow receipt should exist"); assert_eq!(receipt.source_record_id, source_record_id); - assert_eq!(receipt.sdk_operation_kind, TRADE_SUBMIT_OPERATION_KIND); + assert_eq!( + receipt.sdk_operation_kind, + TRADE_SUBMIT_PROPOSAL_OPERATION_KIND + ); assert_eq!( receipt.workflow_state, expected_state, "receipt detail: {}", @@ -20069,7 +19660,7 @@ mod tests { order_id: OrderId, expected_state: DesktopRuntimeWorkflowReceiptState, ) { - let source_record_id = format!("app:order_decision:{order_id}"); + let source_record_id = format!("app:trade_candidate_decision:{order_id}"); let receipt = runtime .lock_state() .sqlite_store @@ -20083,7 +19674,10 @@ mod tests { .expect("SDK workflow receipt should load") .expect("SDK workflow receipt should exist"); assert_eq!(receipt.source_record_id, source_record_id); - assert_eq!(receipt.sdk_operation_kind, TRADE_DECISION_OPERATION_KIND); + assert_eq!( + receipt.sdk_operation_kind, + TRADE_DECIDE_CANDIDATE_OPERATION_KIND + ); assert_eq!( receipt.workflow_state, expected_state, "receipt detail: {}", @@ -20103,8 +19697,8 @@ mod tests { ) { assert_order_runtime_workflow_receipt( runtime, - format!("app:order_cancellation:{order_id}").as_str(), - TRADE_CANCELLATION_OPERATION_KIND, + format!("app:trade_cancellation:{order_id}").as_str(), + TRADE_CANCEL_OPERATION_KIND, expected_state, ); } diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -708,13 +708,16 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::TradeWorkflowAxisAgreement", "AppTextKey::TradeWorkflowAxisRevision", "AppTextKey::TradeWorkflowAxisInventory", + "AppTextKey::TradeWorkflowAxisEvidence", + "AppTextKey::TradeWorkflowAxisConflict", + "AppTextKey::TradeWorkflowAxisPrivateTerms", + "AppTextKey::TradeWorkflowAxisAttestation", "AppTextKey::TradeWorkflowAxisSource", "AppTextKey::TradeWorkflowAgreementRequested", - "AppTextKey::TradeWorkflowAgreementAgreedPendingValidation", "AppTextKey::TradeWorkflowAgreementCommitted", + "AppTextKey::TradeWorkflowAgreementContested", "AppTextKey::TradeWorkflowAgreementDeclined", "AppTextKey::TradeWorkflowAgreementCancelled", - "AppTextKey::TradeWorkflowAgreementValidationExpired", "AppTextKey::TradeWorkflowAgreementInvalid", "AppTextKey::TradeWorkflowRevisionNone", "AppTextKey::TradeWorkflowRevisionChangeProposed", @@ -724,6 +727,26 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::TradeWorkflowInventoryReserved", "AppTextKey::TradeWorkflowInventorySoldOut", "AppTextKey::TradeWorkflowInventoryNeedsReview", + "AppTextKey::TradeWorkflowEvidenceComplete", + "AppTextKey::TradeWorkflowEvidenceMissing", + "AppTextKey::TradeWorkflowEvidenceQueryPartial", + "AppTextKey::TradeWorkflowEvidenceUnsupportedVersion", + "AppTextKey::TradeWorkflowConflictNone", + "AppTextKey::TradeWorkflowConflictConcurrentCandidates", + "AppTextKey::TradeWorkflowConflictDoubleAcceptance", + "AppTextKey::TradeWorkflowConflictDecisionConflict", + "AppTextKey::TradeWorkflowConflictCancellationConflict", + "AppTextKey::TradeWorkflowConflictInvalidCausalChain", + "AppTextKey::TradeWorkflowConflictInventoryAuthorityConflict", + "AppTextKey::TradeWorkflowPrivateTermsNotRequired", + "AppTextKey::TradeWorkflowPrivateTermsAvailableVerified", + "AppTextKey::TradeWorkflowPrivateTermsMissing", + "AppTextKey::TradeWorkflowPrivateTermsUndecryptable", + "AppTextKey::TradeWorkflowPrivateTermsCommitmentMismatch", + "AppTextKey::TradeWorkflowAttestationNone", + "AppTextKey::TradeWorkflowAttestationPresentValid", + "AppTextKey::TradeWorkflowAttestationPresentInvalid", + "AppTextKey::TradeWorkflowAttestationConflicting", "AppTextKey::TradeWorkflowProvenanceApp", "AppTextKey::TradeWorkflowProvenanceCli", "AppTextKey::TradeWorkflowProvenanceRelay", @@ -1083,20 +1106,25 @@ const FORBIDDEN_PAYMENT_ACTION_COPY_TERMS: &[&str] = &[ const FORBIDDEN_PRODUCTION_EVENT_KIND_LITERALS: &[(&str, &str)] = &[ ("30340", "KIND_FARM"), ("30402", "KIND_LISTING"), - ("3422", "KIND_ORDER_REQUEST"), - ("3423", "KIND_ORDER_DECISION"), + ("3422", "RETIRED_COMMERCIAL_KIND_3422"), + ("3423", "RETIRED_COMMERCIAL_KIND_3423"), ("3426", "KIND_TRADE_QUESTION"), ("3427", "KIND_TRADE_ANSWER"), ("3428", "KIND_TRADE_DISCOUNT_REQUEST"), ("3429", "KIND_TRADE_DISCOUNT_OFFER"), ("3430", "KIND_TRADE_DISCOUNT_ACCEPT"), ("3431", "RESERVED_ORDER_KIND_3431"), - ("3432", "KIND_ORDER_CANCELLATION"), + ("3432", "RETIRED_COMMERCIAL_KIND_3432"), ("3433", "KIND_ORDER_FULFILLMENT_UPDATE"), ("3434", "KIND_ORDER_RECEIPT"), ("3435", "KIND_ORDER_PAYMENT_RECORD"), ("3436", "KIND_ORDER_SETTLEMENT_DECISION"), ("3440", "KIND_TRADE_VALIDATION_RECEIPT"), + ("3470", "KIND_TRADE_PROPOSAL"), + ("3471", "KIND_TRADE_DECISION"), + ("3472", "KIND_TRADE_REVISION_PROPOSAL"), + ("3473", "KIND_TRADE_REVISION_DECISION"), + ("3474", "KIND_TRADE_CANCELLATION"), ]; struct SdkBoundaryForbiddenPattern { @@ -1508,21 +1536,18 @@ fn app_production_trade_event_kinds_use_shared_constants() { #[test] fn app_store_validation_receipts_do_not_commit_without_trust_policy() { let source = read_source_path(app_root().join("crates/store/src/interop.rs").as_path()); - let valid_result_branch = "\"valid\" => ("; - let start = source - .find(valid_result_branch) - .expect("store interop should project valid validation receipt results"); - let end = source[start..] - .find("\"needs_review\" => (") - .map(|offset| start + offset) - .expect("store interop should project invalid validation receipt results"); - let branch = &source[start..end]; + let projection = source_segment( + source.as_str(), + "fn upsert_validation_receipt_projection(", + "fn upsert_farm_summary(", + ); - assert!(branch.contains("OrderStatus::NeedsAction.storage_key()")); - assert!(branch.contains("TradeAgreementStatus::AgreedPendingValidation.storage_key()")); - assert!(branch.contains("TradeInventoryStatus::Reserved.storage_key()")); - assert!(!branch.contains("OrderStatus::Scheduled.storage_key()")); - assert!(!branch.contains("TradeAgreementStatus::Committed.storage_key()")); + assert!(projection.contains("let order_id: Option<String> = None;")); + assert!(projection.contains("INSERT INTO order_validation_receipts")); + assert!(!projection.contains("UPDATE orders")); + assert!(!projection.contains("workflow_agreement")); + assert!(!projection.contains("OrderStatus::Scheduled")); + assert!(!projection.contains("TradeAgreementStatus::Committed")); } #[test] @@ -1556,151 +1581,131 @@ fn app_production_sdk_boundary_usage_is_exception_scoped() { } #[test] -fn desktop_runtime_trade_propose_request_stays_product_shaped() { +fn desktop_runtime_trade_requests_stay_semantic_envelope_shaped() { let source = read_source_path(app_root().join("crates/runtime/src/sdk.rs").as_path()); - let request = struct_block(source.as_str(), "DesktopRuntimeTradeProposeRequest"); - assert!( - !request.contains("RadrootsOrderRequest"), - "DesktopRuntimeTradeProposeRequest must not expose protocol-shaped order requests" - ); - for required_field in [ - "pub order_id: RadrootsOrderId", - "pub listing_addr: RadrootsListingAddress", - "pub seller_pubkey: RadrootsPublicKey", - "pub items: Vec<RadrootsOrderItem>", - "pub economics: RadrootsOrderEconomics", - "pub public_note: Option<String>", - "pub confirm_public_note: bool", + for request_struct in [ + "DesktopRuntimeTradeSubmitProposalRequest", + "DesktopRuntimeTradeProposeRevisionRequest", + "DesktopRuntimeTradeDecideCandidateRequest", + "DesktopRuntimeTradeCancelRequest", ] { + let request = struct_block(source.as_str(), request_struct); + for required_field in [ + "pub actor_account_id: String", + "pub actor_pubkey: String", + "pub signer: DesktopRuntimeLocalSigner", + "pub envelope: RadrootsTradeMutationEnvelopeV1", + "pub idempotency_key: Option<String>", + ] { + assert!( + request.contains(required_field), + "{request_struct} is missing semantic SDK field `{required_field}`" + ); + } assert!( - request.contains(required_field), - "DesktopRuntimeTradeProposeRequest is missing product field `{required_field}`" + !request.contains("RadrootsOrder"), + "{request_struct} must not expose order-era protocol types" ); } - assert!(source.contains("fn app_trade_privacy_confirmation(confirm_public_note: bool)")); - assert!(source.contains("if confirm_public_note")); - assert!(source.contains(".with_optional_public_note(request.public_note)")); + + assert!(source.contains("pub acknowledge_private_terms: bool",)); assert!(source.contains( - ".with_privacy_confirmation(app_trade_privacy_confirmation(request.confirm_public_note))" + "sdk.trades()\n .commands()\n .submit_proposal_with_explicit_signer" + )); + assert!(source.contains( + "sdk.trades()\n .commands()\n .propose_revision_with_explicit_signer" + )); + assert!(source.contains( + "sdk.trades()\n .commands()\n .decide_candidate_with_explicit_signer" + )); + assert!(source.contains( + "sdk.trades()\n .commands()\n .cancel_trade_with_explicit_signer" )); - assert!( - !source.contains("fn app_trade_privacy_confirmation()"), - "public-sensitive note confirmation must not become ambient" - ); } #[test] -fn desktop_runtime_trade_mutation_requests_stay_locator_and_resync_owned() { +fn desktop_runtime_trade_mutation_requests_validate_body_and_policy() { let source = read_source_path(app_root().join("crates/runtime/src/sdk.rs").as_path()); - for request_struct in [ - "DesktopRuntimeTradeProposeRequest", - "DesktopRuntimeTradeDecisionRequest", - "DesktopRuntimeTradeCancellationRequest", - ] { - let request = struct_block(source.as_str(), request_struct); - assert!( - !request.contains("evidence_events"), - "{request_struct} must not expose caller-owned evidence events" - ); - } - - for request_struct in [ - "DesktopRuntimeTradeDecisionRequest", - "DesktopRuntimeTradeCancellationRequest", - ] { - let request = struct_block(source.as_str(), request_struct); - assert!( - request.contains("pub locator: RadrootsTradeLocator"), - "{request_struct} must require a root-capable SDK trade locator" - ); - } - - assert!(source.contains("sdk.trades().buyer().propose_trade(sdk_request)")); - for (label, start, end, required_count) in [ + for (label, start, end, required_body) in [ ( "trade proposal", - "fn trade_propose_with_sdk(", - "fn trade_decision_with_sdk(", - 0usize, + "fn trade_submit_proposal_with_sdk(", + "fn trade_propose_revision_with_sdk(", + "RadrootsTradeMutationBodyV1::Proposal", + ), + ( + "trade revision proposal", + "fn trade_propose_revision_with_sdk(", + "fn trade_decide_candidate_with_sdk(", + "RadrootsTradeMutationBodyV1::RevisionProposal", ), ( - "trade decision", - "fn trade_decision_with_sdk(", + "trade candidate decision", + "fn trade_decide_candidate_with_sdk(", "fn trade_cancel_with_sdk(", - 2usize, + "RadrootsTradeMutationBodyV1::Decision", ), ( "trade cancellation", "fn trade_cancel_with_sdk(", "fn sdk_actor_context(", - 1usize, + "RadrootsTradeMutationBodyV1::Cancellation", ), ] { let segment = source_segment(source.as_str(), start, end); + assert!(segment.contains("validate_trade_envelope_body")); assert!( - segment - .matches("TradeEvidenceMode::ResyncBeforeMutation") - .count() - >= required_count, - "{label} must use SDK resync evidence mode before mutation" + segment.contains(required_body), + "{label} must validate semantic mutation body `{required_body}`" ); + assert!(segment.contains("app_trade_target_policy()")); assert!( - !segment.contains("TradeEvidenceMode::LocalOnly"), - "{label} must not use local-only evidence for app mutation authority" + segment.contains("app_trade_satisfaction_policy()"), + "{label} must use SDK satisfaction policy" + ); + assert!( + !segment.contains("TradeEvidenceMode::"), + "{label} must not revive caller-owned evidence mode selection" ); } } #[test] -fn app_store_active_order_projection_is_root_aware_and_non_authoritative() { - let source = read_source_path(app_root().join("crates/store/src/interop.rs").as_path()); - let project_active_order = source_segment( - source.as_str(), - "fn project_active_order(", - "fn upsert_order_request(", - ); - assert!(project_active_order.contains("current_evidence.root_event_id()")); - assert!(project_active_order.contains("root_event_id.as_str()")); - assert!(!project_active_order.contains("current_evidence.order_id()")); - - let load_active_order_evidence = source_segment( - source.as_str(), - "fn load_active_order_evidence(", - "fn replace_order_request_lines(", - ); - assert!(load_active_order_evidence.contains("record.root_event_id() == root_event_id")); - assert!(!load_active_order_evidence.contains("record.order_id()")); - - let active_evidence_identity = source_segment( - source.as_str(), - "fn root_event_id(&self)", - "struct ActiveOrderEvidenceBuckets", - ); - assert!(active_evidence_identity.contains("Self::Request(record) => record.event_id.as_str()")); - assert!( - active_evidence_identity - .contains("Self::Decision(record) => record.root_event_id.as_str()") - ); +fn app_store_has_no_active_order_projection_authority() { + for relative_path in [ + "crates/store/src/interop.rs", + "crates/store/src/lib.rs", + "crates/store/src/repo/workflow.rs", + ] { + let source = read_source_path(app_root().join(relative_path).as_path()); + assert!( + !source.contains("project_active_order") + && !source.contains("load_active_order_evidence") + && !source.contains("ActiveOrderEvidenceBuckets") + && !source.contains("projected_order_id_from_trade_request") + && !source.contains("TradeEvidenceMode::"), + "{relative_path} must not retain old active-order projection authority" + ); + } +} - let projected_order_id = source_segment( - source.as_str(), - "pub fn projected_order_id_from_trade_request(", - "fn active_order_change_status(", - ); - assert!(projected_order_id.contains("root_event_id: &str")); - assert!(projected_order_id.contains("radroots-cli-order-root")); - - for forbidden in [ - "TradeAcceptRequest::new", - "TradeDeclineRequest::new", - "TradeCancelRequest::new", - "TradeEvidenceMode::", +#[test] +fn app_runtime_ingests_every_canonical_trade_mutation_kind() { + let source = read_source_path(app_root().join("crates/desktop/src/runtime.rs").as_path()); + let ingest_kinds = source_segment(source.as_str(), "const APP_DIRECT_RELAY_INGEST_KINDS", "];"); + + for required_kind in [ + "KIND_TRADE_PROPOSAL", + "KIND_TRADE_DECISION", + "KIND_TRADE_REVISION_PROPOSAL", + "KIND_TRADE_REVISION_DECISION", + "KIND_TRADE_CANCELLATION", ] { assert!( - !source.contains(forbidden), - "store projection must not become SDK mutation authority through `{forbidden}`" + ingest_kinds.contains(required_kind), + "direct relay ingest must include canonical mutation kind `{required_kind}`" ); } } @@ -1761,6 +1766,48 @@ fn app_store_current_schema_surfaces_reject_retired_terms() { ); } +#[test] +fn app_store_current_default_schema_omits_buyer_private_plaintext() { + let app_root = app_root(); + let latest_schema = read_source_path( + app_root + .join("crates/store/migrations/0031_drop_default_buyer_private_fields.sql") + .as_path(), + ); + let buyer_repo = read_source_path(app_root.join("crates/store/src/repo/buyer.rs").as_path()); + let buyer_repo_production = + production_source_without_tests("crates/store/src/repo/buyer.rs", buyer_repo.as_str()) + .expect("buyer repo production source should classify"); + let runtime_source = read_source_path(app_root.join("crates/desktop/src/runtime.rs").as_path()); + let runtime_production = + production_source_without_tests("crates/desktop/src/runtime.rs", runtime_source.as_str()) + .expect("runtime production source should classify"); + + for (label, source) in [ + ("latest default schema", latest_schema.as_str()), + ( + "buyer repository production source", + buyer_repo_production.as_str(), + ), + ( + "desktop runtime production source", + runtime_production.as_str(), + ), + ] { + for private_field in [ + "buyer_email", + "buyer_phone", + "buyer_order_note", + "buyer_order_note_public_confirmed", + ] { + assert!( + !source.contains(private_field), + "{label} must not contain default-store private field `{private_field}`" + ); + } + } +} + const APP_STORE_RETIRED_SCHEMA_TERMS: &[&str] = &[ "legacy", "compat", diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -82,7 +82,8 @@ use radroots_studio_app_view::{ ReminderDeliveryState, ReminderId, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, SettingsAccountProjection, ShellSection, TodayAgendaProjection, TodaySetupTaskKind, - TradeAgreementStatus, TradeEconomicsProjection, TradeInventoryStatus, TradeRevisionStatus, + TradeAgreementStatus, TradeAttestationStatus, TradeConflictStatus, TradeEconomicsProjection, + TradeEvidenceStatus, TradeInventoryStatus, TradePrivateTermsStatus, TradeRevisionStatus, TradeValidationReceiptProjection, TradeValidationReceiptResult, TradeValidationReceiptType, TradeWorkflowProjection, TradeWorkflowSource, }; @@ -11588,6 +11589,22 @@ fn trade_workflow_detail_badge_strip(workflow: &TradeWorkflowProjection) -> AnyE AppTextKey::TradeWorkflowAxisInventory, trade_inventory_status_key(workflow.inventory), )); + badges.push(trade_workflow_labeled_key_badge( + AppTextKey::TradeWorkflowAxisEvidence, + trade_evidence_status_key(workflow.evidence), + )); + badges.push(trade_workflow_labeled_key_badge( + AppTextKey::TradeWorkflowAxisConflict, + trade_conflict_status_key(workflow.conflict), + )); + badges.push(trade_workflow_labeled_key_badge( + AppTextKey::TradeWorkflowAxisPrivateTerms, + trade_private_terms_status_key(workflow.private_terms), + )); + badges.push(trade_workflow_labeled_key_badge( + AppTextKey::TradeWorkflowAxisAttestation, + trade_attestation_status_key(workflow.attestation), + )); if workflow.provenance.primary_source != TradeWorkflowSource::Unknown { badges.push(trade_workflow_labeled_key_badge( AppTextKey::TradeWorkflowAxisSource, @@ -11615,6 +11632,26 @@ fn trade_workflow_list_badge_strip(workflow: &TradeWorkflowProjection) -> AnyEle badges.push(trade_workflow_value_badge(trade_inventory_status_key( workflow.inventory, ))); + if workflow.evidence != TradeEvidenceStatus::Complete { + badges.push(trade_workflow_value_badge(trade_evidence_status_key( + workflow.evidence, + ))); + } + if workflow.conflict != TradeConflictStatus::None { + badges.push(trade_workflow_value_badge(trade_conflict_status_key( + workflow.conflict, + ))); + } + if workflow.private_terms != TradePrivateTermsStatus::NotRequired { + badges.push(trade_workflow_value_badge(trade_private_terms_status_key( + workflow.private_terms, + ))); + } + if workflow.attestation != TradeAttestationStatus::None { + badges.push(trade_workflow_value_badge(trade_attestation_status_key( + workflow.attestation, + ))); + } app_cluster(APP_UI_THEME.foundation.spacing.tight_px) .w_full() @@ -11631,6 +11668,12 @@ fn trade_workflow_status_stack(workflow: &TradeWorkflowProjection) -> AnyElement .child(trade_workflow_value_badge(trade_inventory_status_key( workflow.inventory, ))) + .child(trade_workflow_value_badge(trade_evidence_status_key( + workflow.evidence, + ))) + .child(trade_workflow_value_badge(trade_conflict_status_key( + workflow.conflict, + ))) .into_any_element() } @@ -11646,15 +11689,10 @@ fn trade_workflow_value_badge(value_key: AppTextKey) -> AnyElement { fn trade_agreement_status_key(status: TradeAgreementStatus) -> AppTextKey { match status { TradeAgreementStatus::Requested => AppTextKey::TradeWorkflowAgreementRequested, - TradeAgreementStatus::AgreedPendingValidation => { - AppTextKey::TradeWorkflowAgreementAgreedPendingValidation - } TradeAgreementStatus::Committed => AppTextKey::TradeWorkflowAgreementCommitted, + TradeAgreementStatus::Contested => AppTextKey::TradeWorkflowAgreementContested, TradeAgreementStatus::Declined => AppTextKey::TradeWorkflowAgreementDeclined, TradeAgreementStatus::Cancelled => AppTextKey::TradeWorkflowAgreementCancelled, - TradeAgreementStatus::ValidationExpired => { - AppTextKey::TradeWorkflowAgreementValidationExpired - } TradeAgreementStatus::Invalid => AppTextKey::TradeWorkflowAgreementInvalid, } } @@ -11677,6 +11715,64 @@ fn trade_inventory_status_key(status: TradeInventoryStatus) -> AppTextKey { } } +fn trade_evidence_status_key(status: TradeEvidenceStatus) -> AppTextKey { + match status { + TradeEvidenceStatus::Complete => AppTextKey::TradeWorkflowEvidenceComplete, + TradeEvidenceStatus::Missing => AppTextKey::TradeWorkflowEvidenceMissing, + TradeEvidenceStatus::QueryPartial => AppTextKey::TradeWorkflowEvidenceQueryPartial, + TradeEvidenceStatus::UnsupportedVersion => { + AppTextKey::TradeWorkflowEvidenceUnsupportedVersion + } + } +} + +fn trade_conflict_status_key(status: TradeConflictStatus) -> AppTextKey { + match status { + TradeConflictStatus::None => AppTextKey::TradeWorkflowConflictNone, + TradeConflictStatus::ConcurrentCandidates => { + AppTextKey::TradeWorkflowConflictConcurrentCandidates + } + TradeConflictStatus::DoubleAcceptance => AppTextKey::TradeWorkflowConflictDoubleAcceptance, + TradeConflictStatus::DecisionConflict => AppTextKey::TradeWorkflowConflictDecisionConflict, + TradeConflictStatus::CancellationConflict => { + AppTextKey::TradeWorkflowConflictCancellationConflict + } + TradeConflictStatus::InvalidCausalChain => { + AppTextKey::TradeWorkflowConflictInvalidCausalChain + } + TradeConflictStatus::InventoryAuthorityConflict => { + AppTextKey::TradeWorkflowConflictInventoryAuthorityConflict + } + } +} + +fn trade_private_terms_status_key(status: TradePrivateTermsStatus) -> AppTextKey { + match status { + TradePrivateTermsStatus::NotRequired => AppTextKey::TradeWorkflowPrivateTermsNotRequired, + TradePrivateTermsStatus::AvailableVerified => { + AppTextKey::TradeWorkflowPrivateTermsAvailableVerified + } + TradePrivateTermsStatus::Missing => AppTextKey::TradeWorkflowPrivateTermsMissing, + TradePrivateTermsStatus::Undecryptable => { + AppTextKey::TradeWorkflowPrivateTermsUndecryptable + } + TradePrivateTermsStatus::CommitmentMismatch => { + AppTextKey::TradeWorkflowPrivateTermsCommitmentMismatch + } + } +} + +fn trade_attestation_status_key(status: TradeAttestationStatus) -> AppTextKey { + match status { + TradeAttestationStatus::None => AppTextKey::TradeWorkflowAttestationNone, + TradeAttestationStatus::PresentValid => AppTextKey::TradeWorkflowAttestationPresentValid, + TradeAttestationStatus::PresentInvalid => { + AppTextKey::TradeWorkflowAttestationPresentInvalid + } + TradeAttestationStatus::Conflicting => AppTextKey::TradeWorkflowAttestationConflicting, + } +} + fn trade_workflow_source_key(source: TradeWorkflowSource) -> AppTextKey { match source { TradeWorkflowSource::App => AppTextKey::TradeWorkflowProvenanceApp, @@ -16279,8 +16375,9 @@ mod tests { startup_issue_summary_text, startup_notice_text, startup_signer_preview_summary, startup_signer_preview_summary_for_connect_state, startup_signer_source_input_is_editable, startup_signer_status_spec, startup_signer_transport_failure_requires_notice, - trade_agreement_status_key, trade_inventory_status_key, trade_revision_status_key, - trade_workflow_source_key, + trade_agreement_status_key, trade_attestation_status_key, trade_conflict_status_key, + trade_evidence_status_key, trade_inventory_status_key, trade_private_terms_status_key, + trade_revision_status_key, trade_workflow_source_key, }; use crate::runtime::{ DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeSummary, DesktopAppSyncConflictSummary, @@ -16322,7 +16419,8 @@ mod tests { ReminderDeliveryState, ReminderId, ReminderKind, ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TradeAgreementStatus, - TradeEconomicsProjection, TradeInventoryStatus, TradeRevisionStatus, + TradeAttestationStatus, TradeConflictStatus, TradeEconomicsProjection, TradeEvidenceStatus, + TradeInventoryStatus, TradePrivateTermsStatus, TradeRevisionStatus, TradeWorkflowProjection, TradeWorkflowSource, }; use std::{ @@ -16739,14 +16837,14 @@ mod tests { AppTextKey::TradeWorkflowAgreementRequested, ), ( - TradeAgreementStatus::AgreedPendingValidation, - AppTextKey::TradeWorkflowAgreementAgreedPendingValidation, - ), - ( TradeAgreementStatus::Committed, AppTextKey::TradeWorkflowAgreementCommitted, ), ( + TradeAgreementStatus::Contested, + AppTextKey::TradeWorkflowAgreementContested, + ), + ( TradeAgreementStatus::Declined, AppTextKey::TradeWorkflowAgreementDeclined, ), @@ -16755,10 +16853,6 @@ mod tests { AppTextKey::TradeWorkflowAgreementCancelled, ), ( - TradeAgreementStatus::ValidationExpired, - AppTextKey::TradeWorkflowAgreementValidationExpired, - ), - ( TradeAgreementStatus::Invalid, AppTextKey::TradeWorkflowAgreementInvalid, ), @@ -16811,6 +16905,110 @@ mod tests { assert!(!app_text(key).is_empty()); } + for (status, key) in [ + ( + TradeEvidenceStatus::Complete, + AppTextKey::TradeWorkflowEvidenceComplete, + ), + ( + TradeEvidenceStatus::Missing, + AppTextKey::TradeWorkflowEvidenceMissing, + ), + ( + TradeEvidenceStatus::QueryPartial, + AppTextKey::TradeWorkflowEvidenceQueryPartial, + ), + ( + TradeEvidenceStatus::UnsupportedVersion, + AppTextKey::TradeWorkflowEvidenceUnsupportedVersion, + ), + ] { + assert_eq!(trade_evidence_status_key(status), key); + assert!(!app_text(key).is_empty()); + } + + for (status, key) in [ + ( + TradeConflictStatus::None, + AppTextKey::TradeWorkflowConflictNone, + ), + ( + TradeConflictStatus::ConcurrentCandidates, + AppTextKey::TradeWorkflowConflictConcurrentCandidates, + ), + ( + TradeConflictStatus::DoubleAcceptance, + AppTextKey::TradeWorkflowConflictDoubleAcceptance, + ), + ( + TradeConflictStatus::DecisionConflict, + AppTextKey::TradeWorkflowConflictDecisionConflict, + ), + ( + TradeConflictStatus::CancellationConflict, + AppTextKey::TradeWorkflowConflictCancellationConflict, + ), + ( + TradeConflictStatus::InvalidCausalChain, + AppTextKey::TradeWorkflowConflictInvalidCausalChain, + ), + ( + TradeConflictStatus::InventoryAuthorityConflict, + AppTextKey::TradeWorkflowConflictInventoryAuthorityConflict, + ), + ] { + assert_eq!(trade_conflict_status_key(status), key); + assert!(!app_text(key).is_empty()); + } + + for (status, key) in [ + ( + TradePrivateTermsStatus::NotRequired, + AppTextKey::TradeWorkflowPrivateTermsNotRequired, + ), + ( + TradePrivateTermsStatus::AvailableVerified, + AppTextKey::TradeWorkflowPrivateTermsAvailableVerified, + ), + ( + TradePrivateTermsStatus::Missing, + AppTextKey::TradeWorkflowPrivateTermsMissing, + ), + ( + TradePrivateTermsStatus::Undecryptable, + AppTextKey::TradeWorkflowPrivateTermsUndecryptable, + ), + ( + TradePrivateTermsStatus::CommitmentMismatch, + AppTextKey::TradeWorkflowPrivateTermsCommitmentMismatch, + ), + ] { + assert_eq!(trade_private_terms_status_key(status), key); + assert!(!app_text(key).is_empty()); + } + + for (status, key) in [ + ( + TradeAttestationStatus::None, + AppTextKey::TradeWorkflowAttestationNone, + ), + ( + TradeAttestationStatus::PresentValid, + AppTextKey::TradeWorkflowAttestationPresentValid, + ), + ( + TradeAttestationStatus::PresentInvalid, + AppTextKey::TradeWorkflowAttestationPresentInvalid, + ), + ( + TradeAttestationStatus::Conflicting, + AppTextKey::TradeWorkflowAttestationConflicting, + ), + ] { + assert_eq!(trade_attestation_status_key(status), key); + assert!(!app_text(key).is_empty()); + } + for (source, key) in [ ( TradeWorkflowSource::App, diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -431,13 +431,16 @@ define_app_text_keys! { TradeWorkflowAxisAgreement => "trade.workflow.axis.agreement", TradeWorkflowAxisRevision => "trade.workflow.axis.revision", TradeWorkflowAxisInventory => "trade.workflow.axis.inventory", + TradeWorkflowAxisEvidence => "trade.workflow.axis.evidence", + TradeWorkflowAxisConflict => "trade.workflow.axis.conflict", + TradeWorkflowAxisPrivateTerms => "trade.workflow.axis.private_terms", + TradeWorkflowAxisAttestation => "trade.workflow.axis.attestation", TradeWorkflowAxisSource => "trade.workflow.axis.source", TradeWorkflowAgreementRequested => "trade.workflow.agreement.requested", - TradeWorkflowAgreementAgreedPendingValidation => "trade.workflow.agreement.agreed_pending_validation", TradeWorkflowAgreementCommitted => "trade.workflow.agreement.committed", + TradeWorkflowAgreementContested => "trade.workflow.agreement.contested", TradeWorkflowAgreementDeclined => "trade.workflow.agreement.declined", TradeWorkflowAgreementCancelled => "trade.workflow.agreement.cancelled", - TradeWorkflowAgreementValidationExpired => "trade.workflow.agreement.validation_expired", TradeWorkflowAgreementInvalid => "trade.workflow.agreement.invalid", TradeWorkflowRevisionNone => "trade.workflow.revision.none", TradeWorkflowRevisionChangeProposed => "trade.workflow.revision.change_proposed", @@ -447,6 +450,26 @@ define_app_text_keys! { TradeWorkflowInventoryReserved => "trade.workflow.inventory.reserved", TradeWorkflowInventorySoldOut => "trade.workflow.inventory.sold_out", TradeWorkflowInventoryNeedsReview => "trade.workflow.inventory.needs_review", + TradeWorkflowEvidenceComplete => "trade.workflow.evidence.complete", + TradeWorkflowEvidenceMissing => "trade.workflow.evidence.missing", + TradeWorkflowEvidenceQueryPartial => "trade.workflow.evidence.query_partial", + TradeWorkflowEvidenceUnsupportedVersion => "trade.workflow.evidence.unsupported_version", + TradeWorkflowConflictNone => "trade.workflow.conflict.none", + TradeWorkflowConflictConcurrentCandidates => "trade.workflow.conflict.concurrent_candidates", + TradeWorkflowConflictDoubleAcceptance => "trade.workflow.conflict.double_acceptance", + TradeWorkflowConflictDecisionConflict => "trade.workflow.conflict.decision_conflict", + TradeWorkflowConflictCancellationConflict => "trade.workflow.conflict.cancellation_conflict", + TradeWorkflowConflictInvalidCausalChain => "trade.workflow.conflict.invalid_causal_chain", + TradeWorkflowConflictInventoryAuthorityConflict => "trade.workflow.conflict.inventory_authority_conflict", + TradeWorkflowPrivateTermsNotRequired => "trade.workflow.private_terms.not_required", + TradeWorkflowPrivateTermsAvailableVerified => "trade.workflow.private_terms.available_verified", + TradeWorkflowPrivateTermsMissing => "trade.workflow.private_terms.missing", + TradeWorkflowPrivateTermsUndecryptable => "trade.workflow.private_terms.undecryptable", + TradeWorkflowPrivateTermsCommitmentMismatch => "trade.workflow.private_terms.commitment_mismatch", + TradeWorkflowAttestationNone => "trade.workflow.attestation.none", + TradeWorkflowAttestationPresentValid => "trade.workflow.attestation.present_valid", + TradeWorkflowAttestationPresentInvalid => "trade.workflow.attestation.present_invalid", + TradeWorkflowAttestationConflicting => "trade.workflow.attestation.conflicting", TradeWorkflowProvenanceApp => "trade.workflow.provenance.app", TradeWorkflowProvenanceCli => "trade.workflow.provenance.cli", TradeWorkflowProvenanceRelay => "trade.workflow.provenance.relay", diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs @@ -633,24 +633,30 @@ mod tests { app_text(AppTextKey::TradeWorkflowAxisAgreement), "Agreement" ); + assert_eq!(app_text(AppTextKey::TradeWorkflowAxisEvidence), "Evidence"); + assert_eq!(app_text(AppTextKey::TradeWorkflowAxisConflict), "Conflict"); + assert_eq!( + app_text(AppTextKey::TradeWorkflowAxisPrivateTerms), + "Private terms" + ); + assert_eq!( + app_text(AppTextKey::TradeWorkflowAxisAttestation), + "Attestation" + ); assert_eq!(app_text(AppTextKey::TradeWorkflowAxisSource), "Source"); assert_eq!( app_text(AppTextKey::TradeWorkflowAgreementRequested), "Requested" ); assert_eq!( - app_text(AppTextKey::TradeWorkflowAgreementAgreedPendingValidation), - "Pending validation" + app_text(AppTextKey::TradeWorkflowAgreementContested), + "Contested" ); assert_eq!( app_text(AppTextKey::TradeWorkflowAgreementCommitted), "Committed" ); assert_eq!( - app_text(AppTextKey::TradeWorkflowAgreementValidationExpired), - "Validation expired" - ); - assert_eq!( app_text(AppTextKey::TradeWorkflowAgreementInvalid), "Invalid" ); @@ -666,6 +672,22 @@ mod tests { app_text(AppTextKey::TradeWorkflowInventoryReserved), "Reserved" ); + assert_eq!( + app_text(AppTextKey::TradeWorkflowEvidenceMissing), + "Missing" + ); + assert_eq!( + app_text(AppTextKey::TradeWorkflowConflictDoubleAcceptance), + "Double acceptance" + ); + assert_eq!( + app_text(AppTextKey::TradeWorkflowPrivateTermsCommitmentMismatch), + "Commitment mismatch" + ); + assert_eq!( + app_text(AppTextKey::TradeWorkflowAttestationPresentInvalid), + "Invalid" + ); assert_eq!(app_text(AppTextKey::TradeWorkflowProvenanceCli), "CLI"); assert_eq!( app_text(AppTextKey::TradeWorkflowProvenanceRuntimeStore), diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs @@ -51,9 +51,9 @@ pub use sdk::{ DesktopRuntimeStorageDiagnostics, DesktopRuntimeStoragePaths, DesktopRuntimeSupervisor, DesktopRuntimeSupervisorConfig, DesktopRuntimeSupervisorError, DesktopRuntimeSyncDiagnostics, DesktopRuntimeSyncEventStoreDiagnostics, DesktopRuntimeSyncOutboxDiagnostics, - DesktopRuntimeSyncTransportTargetDiagnostics, DesktopRuntimeTradeCancellationRequest, - DesktopRuntimeTradeDecision, DesktopRuntimeTradeDecisionRequest, - DesktopRuntimeTradeProposeRequest, DesktopRuntimeWorkflowReceipt, + DesktopRuntimeSyncTransportTargetDiagnostics, DesktopRuntimeTradeCancelRequest, + DesktopRuntimeTradeDecideCandidateRequest, DesktopRuntimeTradeProposeRevisionRequest, + DesktopRuntimeTradeSubmitProposalRequest, DesktopRuntimeWorkflowReceipt, desktop_runtime_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 @@ -14,36 +14,27 @@ use std::{ use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner}; use radroots_event::{ - RadrootsEventPtr, contract::RadrootsActorRole, farm::{RadrootsFarm, RadrootsFarmPublicLocation}, - ids::{ - RadrootsAddressableCoordinate, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey, - }, + ids::{RadrootsAddressableCoordinate, RadrootsPublicKey}, kinds::KIND_FARM, listing::{RadrootsListing, RadrootsListingPublicLocation}, - order::{ - RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, RadrootsOrderItem, - RadrootsOrderRequest, - }, + trade::{RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1}, }; use radroots_nostr::prelude::RadrootsNostrKeys; use radroots_sdk::SdkMutationState; use radroots_sdk::{ - FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmEnqueueReceipt, IntegrityReceipt, - IntegrityRequest, LISTING_PUBLISH_OPERATION_KIND, ListingEnqueuePublishRequest, - ListingEnqueueReceipt, NostrProfile, NostrRelayUrlPolicy, PrivacyPreflightConfirmation, - ProductSensitivityField, PublishMode, RadrootsClient, RadrootsSdkError, - RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider, RadrootsSdkStoragePaths, RestoreReceipt, - RestoreRequest, SatisfactionPolicy, SdkBackupVerification, SdkPublicLocality, - StorageStatusReceipt, StorageStatusRequest, SyncStatusReceipt, SyncStatusRequest, - TRADE_CANCELLATION_OPERATION_KIND, TRADE_DECISION_OPERATION_KIND, TRADE_SUBMIT_OPERATION_KIND, - TargetPolicy, TradeAcceptRequest, TradeCancelRequest, TradeCancellationPlan, - TradeCancellationReceipt, TradeDecisionPlan, TradeDecisionReceipt, TradeDeclineRequest, - TradeEvidenceMode, TradeMutationOutcome, TradeProposeRequest, TradeSubmitPlan, - TradeSubmitReceipt, TransportProfile, + CancelTradeRequest, DecideCandidateRequest, FARM_PUBLISH_OPERATION_KIND, + FarmEnqueuePublishRequest, FarmEnqueueReceipt, IntegrityReceipt, IntegrityRequest, + LISTING_PUBLISH_OPERATION_KIND, ListingEnqueuePublishRequest, ListingEnqueueReceipt, + NostrProfile, NostrRelayUrlPolicy, ProposeRevisionRequest, RadrootsClient, RadrootsSdkError, + RadrootsSdkStoragePaths, RestoreReceipt, RestoreRequest, SatisfactionPolicy, + SdkBackupVerification, SdkIdempotencyKey, SdkPublicLocality, StorageStatusReceipt, + StorageStatusRequest, SubmitProposalRequest, SyncStatusReceipt, SyncStatusRequest, + TRADE_CANCEL_OPERATION_KIND, TRADE_DECIDE_CANDIDATE_OPERATION_KIND, + TRADE_PROPOSE_REVISION_OPERATION_KIND, TRADE_SUBMIT_PROPOSAL_OPERATION_KIND, TargetPolicy, + TradeCommandReceipt, TransportProfile, }; -use radroots_trade::identity::RadrootsTradeLocator; use serde::Serialize; use serde_json::{Value, json}; use thiserror::Error; @@ -276,47 +267,36 @@ pub struct DesktopRuntimeListingPublishRequest { pub idempotency_key: Option<String>, } -pub struct DesktopRuntimeTradeProposeRequest { +pub struct DesktopRuntimeTradeSubmitProposalRequest { pub actor_account_id: String, pub actor_pubkey: String, pub signer: DesktopRuntimeLocalSigner, - pub listing_event: RadrootsEventPtr, - pub order_id: RadrootsOrderId, - pub listing_addr: RadrootsListingAddress, - pub seller_pubkey: RadrootsPublicKey, - pub items: Vec<RadrootsOrderItem>, - pub economics: RadrootsOrderEconomics, - pub public_note: Option<String>, - pub confirm_public_note: bool, + pub envelope: RadrootsTradeMutationEnvelopeV1, pub idempotency_key: Option<String>, } -pub enum DesktopRuntimeTradeDecision { - Accept { - inventory_commitments: Vec<RadrootsOrderInventoryCommitment>, - }, - Decline { - reason: String, - }, +pub struct DesktopRuntimeTradeProposeRevisionRequest { + pub actor_account_id: String, + pub actor_pubkey: String, + pub signer: DesktopRuntimeLocalSigner, + pub envelope: RadrootsTradeMutationEnvelopeV1, + pub idempotency_key: Option<String>, } -pub struct DesktopRuntimeTradeDecisionRequest { +pub struct DesktopRuntimeTradeDecideCandidateRequest { pub actor_account_id: String, pub actor_pubkey: String, pub signer: DesktopRuntimeLocalSigner, - pub locator: RadrootsTradeLocator, - pub decision: DesktopRuntimeTradeDecision, - pub confirm_public_note: bool, + pub envelope: RadrootsTradeMutationEnvelopeV1, + pub acknowledge_private_terms: bool, pub idempotency_key: Option<String>, } -pub struct DesktopRuntimeTradeCancellationRequest { +pub struct DesktopRuntimeTradeCancelRequest { pub actor_account_id: String, pub actor_pubkey: String, pub signer: DesktopRuntimeLocalSigner, - pub locator: RadrootsTradeLocator, - pub reason: String, - pub confirm_public_note: bool, + pub envelope: RadrootsTradeMutationEnvelopeV1, pub idempotency_key: Option<String>, } @@ -376,9 +356,10 @@ pub enum DesktopRuntimeEffectKind { RestorePreflight, FarmPublish, ListingPublish, - TradePropose, - TradeDecision, - TradeCancellation, + TradeSubmitProposal, + TradeProposeRevision, + TradeDecideCandidate, + TradeCancel, BeginProjectionRebuild, CompleteProjectionRebuild, } @@ -448,17 +429,21 @@ enum DesktopRuntimeEffect { DesktopRuntimeEffectReceipt, Box<DesktopRuntimeListingPublishRequest>, ), - TradePropose( + TradeSubmitProposal( + DesktopRuntimeEffectReceipt, + Box<DesktopRuntimeTradeSubmitProposalRequest>, + ), + TradeProposeRevision( DesktopRuntimeEffectReceipt, - Box<DesktopRuntimeTradeProposeRequest>, + Box<DesktopRuntimeTradeProposeRevisionRequest>, ), - TradeDecision( + TradeDecideCandidate( DesktopRuntimeEffectReceipt, - Box<DesktopRuntimeTradeDecisionRequest>, + Box<DesktopRuntimeTradeDecideCandidateRequest>, ), - TradeCancellation( + TradeCancel( DesktopRuntimeEffectReceipt, - Box<DesktopRuntimeTradeCancellationRequest>, + Box<DesktopRuntimeTradeCancelRequest>, ), BeginProjectionRebuild(DesktopRuntimeEffectReceipt), CompleteProjectionRebuild(DesktopRuntimeEffectReceipt), @@ -471,9 +456,10 @@ impl fmt::Debug for DesktopRuntimeEffect { Self::RestorePreflight(_, _) => formatter.write_str("RestorePreflight"), Self::EnqueueFarmPublish(_, _) => formatter.write_str("EnqueueFarmPublish"), Self::EnqueueListingPublish(_, _) => formatter.write_str("EnqueueListingPublish"), - Self::TradePropose(_, _) => formatter.write_str("TradePropose"), - Self::TradeDecision(_, _) => formatter.write_str("TradeDecision"), - Self::TradeCancellation(_, _) => formatter.write_str("TradeCancellation"), + Self::TradeSubmitProposal(_, _) => formatter.write_str("TradeSubmitProposal"), + Self::TradeProposeRevision(_, _) => formatter.write_str("TradeProposeRevision"), + Self::TradeDecideCandidate(_, _) => formatter.write_str("TradeDecideCandidate"), + Self::TradeCancel(_, _) => formatter.write_str("TradeCancel"), Self::BeginProjectionRebuild(_) => formatter.write_str("BeginProjectionRebuild"), Self::CompleteProjectionRebuild(_) => formatter.write_str("CompleteProjectionRebuild"), } @@ -625,42 +611,55 @@ impl DesktopRuntimeSupervisor { ) } - pub fn trade_propose( + pub fn trade_submit_proposal( &self, - request: DesktopRuntimeTradeProposeRequest, + request: DesktopRuntimeTradeSubmitProposalRequest, ) -> Result<DesktopRuntimeEffectReceipt, DesktopRuntimeSupervisorError> { let actor_pubkey = Some(request.actor_pubkey.clone()); self.submit_effect( - DesktopRuntimeEffectKind::TradePropose, - Some(TRADE_SUBMIT_OPERATION_KIND.to_owned()), + DesktopRuntimeEffectKind::TradeSubmitProposal, + Some(TRADE_SUBMIT_PROPOSAL_OPERATION_KIND.to_owned()), actor_pubkey, - |receipt| DesktopRuntimeEffect::TradePropose(receipt, Box::new(request)), + |receipt| DesktopRuntimeEffect::TradeSubmitProposal(receipt, Box::new(request)), ) } - pub fn trade_decide( + pub fn trade_propose_revision( &self, - request: DesktopRuntimeTradeDecisionRequest, + request: DesktopRuntimeTradeProposeRevisionRequest, ) -> Result<DesktopRuntimeEffectReceipt, DesktopRuntimeSupervisorError> { let actor_pubkey = Some(request.actor_pubkey.clone()); self.submit_effect( - DesktopRuntimeEffectKind::TradeDecision, - Some(TRADE_DECISION_OPERATION_KIND.to_owned()), + DesktopRuntimeEffectKind::TradeProposeRevision, + Some(TRADE_PROPOSE_REVISION_OPERATION_KIND.to_owned()), actor_pubkey, - |receipt| DesktopRuntimeEffect::TradeDecision(receipt, Box::new(request)), + |receipt| DesktopRuntimeEffect::TradeProposeRevision(receipt, Box::new(request)), + ) + } + + pub fn trade_decide_candidate( + &self, + request: DesktopRuntimeTradeDecideCandidateRequest, + ) -> Result<DesktopRuntimeEffectReceipt, DesktopRuntimeSupervisorError> { + let actor_pubkey = Some(request.actor_pubkey.clone()); + self.submit_effect( + DesktopRuntimeEffectKind::TradeDecideCandidate, + Some(TRADE_DECIDE_CANDIDATE_OPERATION_KIND.to_owned()), + actor_pubkey, + |receipt| DesktopRuntimeEffect::TradeDecideCandidate(receipt, Box::new(request)), ) } pub fn trade_cancel( &self, - request: DesktopRuntimeTradeCancellationRequest, + request: DesktopRuntimeTradeCancelRequest, ) -> Result<DesktopRuntimeEffectReceipt, DesktopRuntimeSupervisorError> { let actor_pubkey = Some(request.actor_pubkey.clone()); self.submit_effect( - DesktopRuntimeEffectKind::TradeCancellation, - Some(TRADE_CANCELLATION_OPERATION_KIND.to_owned()), + DesktopRuntimeEffectKind::TradeCancel, + Some(TRADE_CANCEL_OPERATION_KIND.to_owned()), actor_pubkey, - |receipt| DesktopRuntimeEffect::TradeCancellation(receipt, Box::new(request)), + |receipt| DesktopRuntimeEffect::TradeCancel(receipt, Box::new(request)), ) } @@ -1189,29 +1188,40 @@ fn run_desktop_runtime_worker( }; finish_workflow_result(&shared, receipt, result); } - DesktopRuntimeEffect::TradePropose(receipt, request) => { + DesktopRuntimeEffect::TradeSubmitProposal(receipt, request) => { + let result = if let Some(issue) = lifecycle_busy_issue(&shared) { + Err(issue) + } else { + match sdk.as_ref() { + Some(_) => trade_submit_proposal_with_sdk(&runtime, &config, *request), + None => Err(runtime_unavailable_issue(&shared)), + } + }; + finish_workflow_result(&shared, receipt, result); + } + DesktopRuntimeEffect::TradeProposeRevision(receipt, request) => { let result = if let Some(issue) = lifecycle_busy_issue(&shared) { Err(issue) } else { match sdk.as_ref() { - Some(_) => trade_propose_with_sdk(&runtime, &config, *request), + Some(_) => trade_propose_revision_with_sdk(&runtime, &config, *request), None => Err(runtime_unavailable_issue(&shared)), } }; finish_workflow_result(&shared, receipt, result); } - DesktopRuntimeEffect::TradeDecision(receipt, request) => { + DesktopRuntimeEffect::TradeDecideCandidate(receipt, request) => { let result = if let Some(issue) = lifecycle_busy_issue(&shared) { Err(issue) } else { match sdk.as_ref() { - Some(_) => trade_decision_with_sdk(&runtime, &config, *request), + Some(_) => trade_decide_candidate_with_sdk(&runtime, &config, *request), None => Err(runtime_unavailable_issue(&shared)), } }; finish_workflow_result(&shared, receipt, result); } - DesktopRuntimeEffect::TradeCancellation(receipt, request) => { + DesktopRuntimeEffect::TradeCancel(receipt, request) => { let result = if let Some(issue) = lifecycle_busy_issue(&shared) { Err(issue) } else { @@ -1291,26 +1301,6 @@ async fn build_sdk_runtime( .await } -async fn build_sdk_runtime_with_signer( - config: &DesktopRuntimeSupervisorConfig, - signer: DesktopRuntimeLocalSigner, -) -> Result<RadrootsClient, DesktopRuntimeIssue> { - let local_signer = RadrootsLocalEventSigner::new(signer.into_keys()).map_err(|error| { - DesktopRuntimeIssue::runtime_error("sdk_signer_init_failed", error.to_string()) - })?; - let signer = RadrootsSdkLocalKeySigner::from_event_signer(local_signer) - .map_err(|error| DesktopRuntimeIssue::from_sdk_error(&error))?; - let transport_profile = app_transport_profile(config) - .map_err(|error| DesktopRuntimeIssue::from_sdk_error(&error))?; - RadrootsClient::builder() - .directory_storage(config.storage_root.clone()) - .transport_profile(transport_profile) - .signer_provider(RadrootsSdkSignerProvider::LocalKey(signer)) - .build() - .await - .map_err(|error| DesktopRuntimeIssue::from_sdk_error(&error)) -} - fn app_transport_profile( config: &DesktopRuntimeSupervisorConfig, ) -> Result<TransportProfile, RadrootsSdkError> { @@ -1323,10 +1313,6 @@ fn app_transport_profile( )?)) } -fn app_trade_publish_mode() -> PublishMode { - PublishMode::EnqueueOnly -} - fn app_trade_satisfaction_policy() -> SatisfactionPolicy { SatisfactionPolicy::NoWait } @@ -1335,15 +1321,6 @@ fn app_trade_target_policy() -> TargetPolicy { TargetPolicy::default_profile() } -fn app_trade_privacy_confirmation(confirm_public_note: bool) -> PrivacyPreflightConfirmation { - if confirm_public_note { - PrivacyPreflightConfirmation::new() - .confirm(ProductSensitivityField::PublicButSensitiveNotes) - } else { - PrivacyPreflightConfirmation::new() - } -} - fn block_on_sdk_result<T>( runtime: &tokio::runtime::Runtime, operation: &'static str, @@ -1362,23 +1339,6 @@ fn block_on_sdk_result<T>( } } -fn block_on_desktop_result<T>( - runtime: &tokio::runtime::Runtime, - operation: &'static str, - future: impl Future<Output = Result<T, DesktopRuntimeIssue>>, -) -> Result<T, DesktopRuntimeIssue> { - match runtime.block_on(async { - tokio::time::timeout( - Duration::from_millis(DESKTOP_RUNTIME_SDK_EFFECT_TIMEOUT_MS), - future, - ) - .await - }) { - Ok(result) => result, - Err(_) => Err(sdk_effect_timeout_issue(operation)), - } -} - fn sdk_effect_timeout_issue(operation: &'static str) -> DesktopRuntimeIssue { DesktopRuntimeIssue::runtime_error( "desktop_runtime_sdk_effect_timeout", @@ -1544,162 +1504,162 @@ fn enqueue_listing_publish_with_sdk( )) } -fn trade_propose_with_sdk( +fn trade_submit_proposal_with_sdk( runtime: &tokio::runtime::Runtime, config: &DesktopRuntimeSupervisorConfig, - request: DesktopRuntimeTradeProposeRequest, + request: DesktopRuntimeTradeSubmitProposalRequest, ) -> Result<DesktopRuntimeWorkflowReceipt, DesktopRuntimeIssue> { - let actor = sdk_actor_context( + validate_trade_envelope_body(&request.envelope, "proposal", |body| { + matches!(body, RadrootsTradeMutationBodyV1::Proposal { .. }) + })?; + let actor = sdk_trade_actor_context( request.actor_pubkey.as_str(), request.actor_account_id.as_str(), - RadrootsActorRole::Buyer, + &request.envelope, )?; - let sdk = block_on_desktop_result( + let signer = sdk_local_signer(request.signer)?; + let sdk = block_on_sdk_result( runtime, - "desktop_runtime_trade_propose_build", - build_sdk_runtime_with_signer(config, request.signer), + "desktop_runtime_trade_submit_build", + build_sdk_runtime(config), )?; - let buyer_pubkey = - RadrootsPublicKey::parse(request.actor_pubkey.as_str()).map_err(|error| { - DesktopRuntimeIssue::from_sdk_error(&RadrootsSdkError::InvalidRequest { - message: format!("trade proposal buyer public key is invalid: {error}"), - }) - })?; - let order = RadrootsOrderRequest { - order_id: request.order_id, - listing_addr: request.listing_addr, - buyer_pubkey, - seller_pubkey: request.seller_pubkey, - items: request.items, - economics: request.economics, - }; - let mut sdk_request = TradeProposeRequest::new( - actor, - request.listing_event, - order, - app_trade_target_policy(), - app_trade_publish_mode(), - app_trade_satisfaction_policy(), - ) - .with_optional_public_note(request.public_note) - .with_privacy_confirmation(app_trade_privacy_confirmation(request.confirm_public_note)); + let mut sdk_request = + SubmitProposalRequest::new(actor, request.envelope, app_trade_target_policy()) + .with_satisfaction_policy(app_trade_satisfaction_policy()); if let Some(idempotency_key) = request.idempotency_key.as_deref() { sdk_request = sdk_request .try_with_idempotency_key(idempotency_key) .map_err(|error| DesktopRuntimeIssue::from_sdk_error(&error))?; } - let outcome = block_on_sdk_result( + let receipt = block_on_sdk_result( runtime, - "desktop_runtime_trade_propose", - sdk.trades().buyer().propose_trade(sdk_request), + "desktop_runtime_trade_submit_proposal", + sdk.trades() + .commands() + .submit_proposal_with_explicit_signer(sdk_request, &signer), )?; - desktop_runtime_trade_propose_receipt(outcome, request.actor_pubkey) + Ok(desktop_runtime_trade_receipt(receipt, request.actor_pubkey)) } -fn trade_decision_with_sdk( +fn trade_propose_revision_with_sdk( runtime: &tokio::runtime::Runtime, config: &DesktopRuntimeSupervisorConfig, - request: DesktopRuntimeTradeDecisionRequest, + request: DesktopRuntimeTradeProposeRevisionRequest, ) -> Result<DesktopRuntimeWorkflowReceipt, DesktopRuntimeIssue> { - let actor = sdk_actor_context( + validate_trade_envelope_body(&request.envelope, "revision proposal", |body| { + matches!(body, RadrootsTradeMutationBodyV1::RevisionProposal { .. }) + })?; + let actor = sdk_trade_actor_context( request.actor_pubkey.as_str(), request.actor_account_id.as_str(), - RadrootsActorRole::Seller, + &request.envelope, )?; - let sdk = block_on_desktop_result( + let signer = sdk_local_signer(request.signer)?; + let sdk = block_on_sdk_result( + runtime, + "desktop_runtime_trade_revision_build", + build_sdk_runtime(config), + )?; + let mut sdk_request = + ProposeRevisionRequest::new(actor, request.envelope, app_trade_target_policy()) + .with_satisfaction_policy(app_trade_satisfaction_policy()); + if let Some(idempotency_key) = request.idempotency_key.as_deref() { + sdk_request = sdk_request.with_idempotency_key( + SdkIdempotencyKey::new(idempotency_key) + .map_err(|error| DesktopRuntimeIssue::from_sdk_error(&error))?, + ); + } + let receipt = block_on_sdk_result( + runtime, + "desktop_runtime_trade_propose_revision", + sdk.trades() + .commands() + .propose_revision_with_explicit_signer(sdk_request, &signer), + )?; + Ok(desktop_runtime_trade_receipt(receipt, request.actor_pubkey)) +} + +fn trade_decide_candidate_with_sdk( + runtime: &tokio::runtime::Runtime, + config: &DesktopRuntimeSupervisorConfig, + request: DesktopRuntimeTradeDecideCandidateRequest, +) -> Result<DesktopRuntimeWorkflowReceipt, DesktopRuntimeIssue> { + validate_trade_envelope_body(&request.envelope, "candidate decision", |body| { + matches!( + body, + RadrootsTradeMutationBodyV1::Decision { .. } + | RadrootsTradeMutationBodyV1::RevisionDecision { .. } + ) + })?; + let actor = sdk_trade_actor_context( + request.actor_pubkey.as_str(), + request.actor_account_id.as_str(), + &request.envelope, + )?; + let signer = sdk_local_signer(request.signer)?; + let sdk = block_on_sdk_result( runtime, "desktop_runtime_trade_decision_build", - build_sdk_runtime_with_signer(config, request.signer), + build_sdk_runtime(config), )?; - let publish_mode = app_trade_publish_mode(); - let satisfaction_policy = app_trade_satisfaction_policy(); - let outcome = match request.decision { - DesktopRuntimeTradeDecision::Accept { - inventory_commitments, - } => { - let mut sdk_request = TradeAcceptRequest::new( - actor, - request.locator, - inventory_commitments, - app_trade_target_policy(), - publish_mode, - satisfaction_policy, - TradeEvidenceMode::ResyncBeforeMutation, - ) - .with_privacy_confirmation(app_trade_privacy_confirmation(false)); - if let Some(idempotency_key) = request.idempotency_key.as_deref() { - sdk_request = sdk_request - .try_with_idempotency_key(idempotency_key) - .map_err(|error| DesktopRuntimeIssue::from_sdk_error(&error))?; - } - block_on_sdk_result( - runtime, - "desktop_runtime_trade_accept", - sdk.trades().seller().accept_trade(sdk_request), - )? - } - DesktopRuntimeTradeDecision::Decline { reason } => { - let mut sdk_request = TradeDeclineRequest::new( - actor, - request.locator, - reason, - app_trade_target_policy(), - publish_mode, - satisfaction_policy, - TradeEvidenceMode::ResyncBeforeMutation, - ) - .with_privacy_confirmation(app_trade_privacy_confirmation(request.confirm_public_note)); - if let Some(idempotency_key) = request.idempotency_key.as_deref() { - sdk_request = sdk_request - .try_with_idempotency_key(idempotency_key) - .map_err(|error| DesktopRuntimeIssue::from_sdk_error(&error))?; - } - block_on_sdk_result( - runtime, - "desktop_runtime_trade_decline", - sdk.trades().seller().decline_trade(sdk_request), - )? - } - }; - desktop_runtime_trade_decision_receipt(outcome, request.actor_pubkey) + let mut sdk_request = + DecideCandidateRequest::new(actor, request.envelope, app_trade_target_policy()) + .with_satisfaction_policy(app_trade_satisfaction_policy()); + if request.acknowledge_private_terms { + sdk_request = sdk_request.acknowledge_private_terms(); + } + if let Some(idempotency_key) = request.idempotency_key.as_deref() { + sdk_request = sdk_request.with_idempotency_key( + SdkIdempotencyKey::new(idempotency_key) + .map_err(|error| DesktopRuntimeIssue::from_sdk_error(&error))?, + ); + } + let receipt = block_on_sdk_result( + runtime, + "desktop_runtime_trade_decide_candidate", + sdk.trades() + .commands() + .decide_candidate_with_explicit_signer(sdk_request, &signer), + )?; + Ok(desktop_runtime_trade_receipt(receipt, request.actor_pubkey)) } fn trade_cancel_with_sdk( runtime: &tokio::runtime::Runtime, config: &DesktopRuntimeSupervisorConfig, - request: DesktopRuntimeTradeCancellationRequest, + request: DesktopRuntimeTradeCancelRequest, ) -> Result<DesktopRuntimeWorkflowReceipt, DesktopRuntimeIssue> { - let actor = sdk_actor_context( + validate_trade_envelope_body(&request.envelope, "cancellation", |body| { + matches!(body, RadrootsTradeMutationBodyV1::Cancellation { .. }) + })?; + let actor = sdk_trade_actor_context( request.actor_pubkey.as_str(), request.actor_account_id.as_str(), - RadrootsActorRole::Buyer, + &request.envelope, )?; - let sdk = block_on_desktop_result( + let signer = sdk_local_signer(request.signer)?; + let sdk = block_on_sdk_result( runtime, "desktop_runtime_trade_cancel_build", - build_sdk_runtime_with_signer(config, request.signer), + build_sdk_runtime(config), )?; - let mut sdk_request = TradeCancelRequest::new( - actor, - request.locator, - request.reason, - app_trade_target_policy(), - app_trade_publish_mode(), - app_trade_satisfaction_policy(), - TradeEvidenceMode::ResyncBeforeMutation, - ) - .with_privacy_confirmation(app_trade_privacy_confirmation(request.confirm_public_note)); + let mut sdk_request = + CancelTradeRequest::new(actor, request.envelope, app_trade_target_policy()); + sdk_request.satisfaction_policy = app_trade_satisfaction_policy(); if let Some(idempotency_key) = request.idempotency_key.as_deref() { - sdk_request = sdk_request - .try_with_idempotency_key(idempotency_key) - .map_err(|error| DesktopRuntimeIssue::from_sdk_error(&error))?; + sdk_request = sdk_request.with_idempotency_key( + SdkIdempotencyKey::new(idempotency_key) + .map_err(|error| DesktopRuntimeIssue::from_sdk_error(&error))?, + ); } - let outcome = block_on_sdk_result( + let receipt = block_on_sdk_result( runtime, "desktop_runtime_trade_cancel", - sdk.trades().buyer().cancel_trade(sdk_request), + sdk.trades() + .commands() + .cancel_trade_with_explicit_signer(sdk_request, &signer), )?; - desktop_runtime_trade_cancellation_receipt(outcome, request.actor_pubkey) + Ok(desktop_runtime_trade_receipt(receipt, request.actor_pubkey)) } fn sdk_actor_context( @@ -1712,6 +1672,53 @@ fn sdk_actor_context( ) } +fn sdk_trade_actor_context( + actor_pubkey: &str, + actor_account_id: &str, + envelope: &RadrootsTradeMutationEnvelopeV1, +) -> Result<RadrootsActorContext, DesktopRuntimeIssue> { + let parsed_actor = RadrootsPublicKey::parse(actor_pubkey).map_err(|error| { + DesktopRuntimeIssue::from_sdk_error(&RadrootsSdkError::InvalidRequest { + message: format!("trade command actor public key is invalid: {error}"), + }) + })?; + if parsed_actor != envelope.author_pubkey { + return Err(DesktopRuntimeIssue::from_sdk_error( + &RadrootsSdkError::InvalidRequest { + message: "trade command actor must match the mutation envelope author".to_owned(), + }, + )); + } + let role = if envelope.author_pubkey == envelope.buyer_pubkey { + RadrootsActorRole::Buyer + } else if envelope.author_pubkey == envelope.seller_pubkey { + RadrootsActorRole::Seller + } else { + return Err(DesktopRuntimeIssue::from_sdk_error( + &RadrootsSdkError::InvalidRequest { + message: "trade command author must be the buyer or seller".to_owned(), + }, + )); + }; + sdk_actor_context(actor_pubkey, actor_account_id, role) +} + +fn validate_trade_envelope_body( + envelope: &RadrootsTradeMutationEnvelopeV1, + expected: &'static str, + accepts: impl FnOnce(&RadrootsTradeMutationBodyV1) -> bool, +) -> Result<(), DesktopRuntimeIssue> { + if accepts(&envelope.body) { + Ok(()) + } else { + Err(DesktopRuntimeIssue::from_sdk_error( + &RadrootsSdkError::InvalidRequest { + message: format!("trade command requires a {expected} mutation envelope"), + }, + )) + } +} + fn sdk_local_signer( signer: DesktopRuntimeLocalSigner, ) -> Result<RadrootsLocalEventSigner, DesktopRuntimeIssue> { @@ -1784,73 +1791,22 @@ fn desktop_runtime_listing_receipt( } } -fn desktop_runtime_trade_propose_receipt( - outcome: TradeMutationOutcome<TradeSubmitPlan, TradeSubmitReceipt>, - actor_pubkey: String, -) -> Result<DesktopRuntimeWorkflowReceipt, DesktopRuntimeIssue> { - match outcome { - TradeMutationOutcome::Enqueued { receipt } - | TradeMutationOutcome::Published { receipt, .. } => Ok(DesktopRuntimeWorkflowReceipt { - operation_kind: TRADE_SUBMIT_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, - }), - TradeMutationOutcome::DryRun { .. } => Err(unexpected_trade_dry_run_issue("trade.propose")), - } -} - -fn desktop_runtime_trade_decision_receipt( - outcome: TradeMutationOutcome<TradeDecisionPlan, TradeDecisionReceipt>, +fn desktop_runtime_trade_receipt( + receipt: TradeCommandReceipt, actor_pubkey: String, -) -> Result<DesktopRuntimeWorkflowReceipt, DesktopRuntimeIssue> { - match outcome { - TradeMutationOutcome::Enqueued { receipt } - | TradeMutationOutcome::Published { receipt, .. } => Ok(DesktopRuntimeWorkflowReceipt { - operation_kind: TRADE_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, - }), - TradeMutationOutcome::DryRun { .. } => Err(unexpected_trade_dry_run_issue("trade.decide")), - } -} - -fn desktop_runtime_trade_cancellation_receipt( - outcome: TradeMutationOutcome<TradeCancellationPlan, TradeCancellationReceipt>, - actor_pubkey: String, -) -> Result<DesktopRuntimeWorkflowReceipt, DesktopRuntimeIssue> { - match outcome { - TradeMutationOutcome::Enqueued { receipt } - | TradeMutationOutcome::Published { receipt, .. } => Ok(DesktopRuntimeWorkflowReceipt { - operation_kind: TRADE_CANCELLATION_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, - }), - TradeMutationOutcome::DryRun { .. } => Err(unexpected_trade_dry_run_issue("trade.cancel")), +) -> DesktopRuntimeWorkflowReceipt { + DesktopRuntimeWorkflowReceipt { + operation_kind: receipt.operation_kind, + 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.delivery_state).to_owned(), + idempotency_digest_prefix: Some(receipt.idempotency_digest_prefix), + actor_pubkey, } } -fn unexpected_trade_dry_run_issue(operation: &'static str) -> DesktopRuntimeIssue { - DesktopRuntimeIssue::runtime_error( - "sdk_trade_unexpected_dry_run", - format!("{operation} returned a dry-run plan for an enqueue-only Studio command"), - ) -} - fn sdk_mutation_state_key(state: SdkMutationState) -> &'static str { match state { SdkMutationState::StoredAndQueued => "enqueued", @@ -2002,9 +1958,10 @@ fn effect_receipt(effect: &DesktopRuntimeEffect) -> DesktopRuntimeEffectReceipt | DesktopRuntimeEffect::RestorePreflight(receipt, _) | DesktopRuntimeEffect::EnqueueFarmPublish(receipt, _) | DesktopRuntimeEffect::EnqueueListingPublish(receipt, _) - | DesktopRuntimeEffect::TradePropose(receipt, _) - | DesktopRuntimeEffect::TradeDecision(receipt, _) - | DesktopRuntimeEffect::TradeCancellation(receipt, _) + | DesktopRuntimeEffect::TradeSubmitProposal(receipt, _) + | DesktopRuntimeEffect::TradeProposeRevision(receipt, _) + | DesktopRuntimeEffect::TradeDecideCandidate(receipt, _) + | DesktopRuntimeEffect::TradeCancel(receipt, _) | DesktopRuntimeEffect::BeginProjectionRebuild(receipt) | DesktopRuntimeEffect::CompleteProjectionRebuild(receipt) => receipt.clone(), } diff --git a/crates/store/migrations/0031_drop_default_buyer_private_fields.sql b/crates/store/migrations/0031_drop_default_buyer_private_fields.sql @@ -0,0 +1,320 @@ +DROP INDEX IF EXISTS idx_order_validation_receipts_order_time; +DROP INDEX IF EXISTS idx_order_validation_receipts_root_order; +DROP INDEX IF EXISTS idx_order_lines_order_sort; +DROP INDEX IF EXISTS idx_buyer_order_coordination_context_state_updated_at; +DROP INDEX IF EXISTS idx_buyer_order_coordination_state_updated_at; +DROP INDEX IF EXISTS idx_orders_farm_status; +DROP INDEX IF EXISTS idx_orders_farm_window_status_updated_at; +DROP INDEX IF EXISTS idx_orders_buyer_context_updated_at; +DROP INDEX IF EXISTS idx_buyer_cart_lines_context_updated_at; + +ALTER TABLE order_validation_receipts RENAME TO order_validation_receipts_public_private_previous; +ALTER TABLE order_lines RENAME TO order_lines_public_private_previous; +ALTER TABLE buyer_order_coordination_records RENAME TO buyer_order_coordination_records_public_private_previous; +ALTER TABLE orders RENAME TO orders_public_private_previous; +ALTER TABLE buyer_cart_lines RENAME TO buyer_cart_lines_public_private_previous; +ALTER TABLE buyer_carts RENAME TO buyer_carts_public_private_previous; + +CREATE TABLE buyer_carts ( + buyer_context_key TEXT PRIMARY KEY NOT NULL, + farm_id TEXT REFERENCES farms(id) ON DELETE SET NULL, + buyer_name TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL +); + +INSERT INTO buyer_carts ( + buyer_context_key, + farm_id, + buyer_name, + updated_at +) +SELECT + buyer_context_key, + farm_id, + buyer_name, + updated_at +FROM buyer_carts_public_private_previous; + +CREATE TABLE buyer_cart_lines ( + buyer_context_key TEXT NOT NULL REFERENCES buyer_carts(buyer_context_key) ON DELETE CASCADE, + product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL CHECK (quantity > 0), + updated_at TEXT NOT NULL, + listing_bin_id TEXT, + quantity_unit_label TEXT, + unit_price_minor_units INTEGER CHECK ( + unit_price_minor_units IS NULL OR unit_price_minor_units >= 0 + ), + price_currency TEXT, + farm_key TEXT, + listing_addr TEXT, + listing_event_id TEXT, + seller_pubkey TEXT, + listing_relays_json TEXT, + PRIMARY KEY (buyer_context_key, product_id) +); + +INSERT INTO buyer_cart_lines ( + buyer_context_key, + product_id, + quantity, + updated_at, + listing_bin_id, + quantity_unit_label, + unit_price_minor_units, + price_currency, + farm_key, + listing_addr, + listing_event_id, + seller_pubkey, + listing_relays_json +) +SELECT + buyer_context_key, + product_id, + quantity, + updated_at, + listing_bin_id, + quantity_unit_label, + unit_price_minor_units, + price_currency, + farm_key, + listing_addr, + listing_event_id, + seller_pubkey, + listing_relays_json +FROM buyer_cart_lines_public_private_previous; + +CREATE TABLE orders ( + id TEXT PRIMARY KEY NOT NULL, + farm_id TEXT NOT NULL REFERENCES farms(id) ON DELETE CASCADE, + fulfillment_window_id TEXT REFERENCES fulfillment_windows(id) ON DELETE SET NULL, + order_number TEXT NOT NULL, + customer_display_name TEXT NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('needs_action', 'scheduled', 'packed', 'completed', 'declined', 'needs_review') + ), + updated_at TEXT NOT NULL, + buyer_context_key TEXT, + workflow_revision TEXT NOT NULL DEFAULT 'none' CHECK ( + workflow_revision IN ('none', 'change_proposed', 'updated', 'kept_as_placed') + ), + workflow_agreement TEXT NOT NULL DEFAULT 'requested' CHECK ( + workflow_agreement IN ('requested', 'committed', 'contested', 'declined', 'cancelled', 'invalid') + ), + workflow_inventory TEXT NOT NULL DEFAULT 'needs_review' CHECK ( + workflow_inventory IN ('available', 'reserved', 'sold_out', 'needs_review') + ), + workflow_provenance_source TEXT NOT NULL DEFAULT 'unknown' CHECK ( + workflow_provenance_source IN ('app', 'cli', 'relay', 'runtime_store', 'unknown') + ), + workflow_provenance_last_event_id TEXT +); + +INSERT INTO orders ( + id, + farm_id, + fulfillment_window_id, + order_number, + customer_display_name, + status, + updated_at, + buyer_context_key, + workflow_revision, + workflow_agreement, + workflow_inventory, + workflow_provenance_source, + workflow_provenance_last_event_id +) +SELECT + id, + farm_id, + fulfillment_window_id, + order_number, + customer_display_name, + status, + updated_at, + buyer_context_key, + workflow_revision, + CASE workflow_agreement + WHEN 'agreed_pending_validation' THEN 'contested' + WHEN 'validation_expired' THEN 'invalid' + ELSE workflow_agreement + END, + workflow_inventory, + workflow_provenance_source, + workflow_provenance_last_event_id +FROM orders_public_private_previous; + +CREATE TABLE order_lines ( + id TEXT PRIMARY KEY NOT NULL, + order_id TEXT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + title TEXT NOT NULL, + quantity_value INTEGER NOT NULL CHECK (quantity_value >= 0), + quantity_unit_label TEXT NOT NULL DEFAULT '', + quantity_display TEXT NOT NULL, + sort_index INTEGER NOT NULL DEFAULT 0, + listing_bin_id TEXT, + unit_price_minor_units INTEGER CHECK ( + unit_price_minor_units IS NULL OR unit_price_minor_units >= 0 + ), + price_currency TEXT NOT NULL DEFAULT 'USD', + farm_key TEXT, + listing_addr TEXT, + listing_event_id TEXT, + seller_pubkey TEXT, + listing_relays_json TEXT +); + +INSERT INTO order_lines ( + id, + order_id, + title, + quantity_value, + quantity_unit_label, + quantity_display, + sort_index, + listing_bin_id, + unit_price_minor_units, + price_currency, + farm_key, + listing_addr, + listing_event_id, + seller_pubkey, + listing_relays_json +) +SELECT + id, + order_id, + title, + quantity_value, + quantity_unit_label, + quantity_display, + sort_index, + listing_bin_id, + unit_price_minor_units, + price_currency, + farm_key, + listing_addr, + listing_event_id, + seller_pubkey, + listing_relays_json +FROM order_lines_public_private_previous; + +CREATE TABLE buyer_order_coordination_records ( + order_id TEXT PRIMARY KEY NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + buyer_context_key TEXT NOT NULL, + record_id TEXT, + state TEXT NOT NULL CHECK (state IN ('pending', 'synced', 'failed')), + payload_json TEXT, + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + last_error_message TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + synced_at TEXT +); + +INSERT INTO buyer_order_coordination_records ( + order_id, + buyer_context_key, + record_id, + state, + payload_json, + attempt_count, + last_error_message, + created_at, + updated_at, + synced_at +) +SELECT + order_id, + buyer_context_key, + record_id, + state, + payload_json, + attempt_count, + last_error_message, + created_at, + updated_at, + synced_at +FROM buyer_order_coordination_records_public_private_previous; + +CREATE TABLE order_validation_receipts ( + event_id TEXT PRIMARY KEY NOT NULL, + order_id TEXT REFERENCES orders(id) ON DELETE CASCADE, + raw_order_id TEXT NOT NULL, + root_event_id TEXT NOT NULL, + listing_event_id TEXT NOT NULL, + target_event_id TEXT NOT NULL, + receipt_type TEXT NOT NULL CHECK ( + receipt_type IN ('listing_validation', 'trade_transition', 'inventory_state', 'state_checkpoint') + ), + result TEXT NOT NULL CHECK ( + result IN ('valid', 'needs_review') + ), + proof_system TEXT NOT NULL CHECK ( + proof_system IN ('none', 'sp1_core', 'sp1_compressed', 'sp1_groth16', 'sp1_plonk') + ), + event_set_root TEXT NOT NULL, + reducer_output_root TEXT NOT NULL, + public_values_hash TEXT NOT NULL, + event_created_at INTEGER NOT NULL CHECK (event_created_at >= 0) +); + +INSERT INTO order_validation_receipts ( + event_id, + order_id, + raw_order_id, + root_event_id, + listing_event_id, + target_event_id, + receipt_type, + result, + proof_system, + event_set_root, + reducer_output_root, + public_values_hash, + event_created_at +) +SELECT + event_id, + order_id, + raw_order_id, + root_event_id, + listing_event_id, + target_event_id, + receipt_type, + result, + proof_system, + event_set_root, + reducer_output_root, + public_values_hash, + event_created_at +FROM order_validation_receipts_public_private_previous; + +CREATE INDEX idx_buyer_cart_lines_context_updated_at + ON buyer_cart_lines(buyer_context_key, updated_at DESC, product_id DESC); +CREATE INDEX idx_orders_farm_status ON orders(farm_id, status); +CREATE INDEX idx_orders_farm_window_status_updated_at + ON orders(farm_id, fulfillment_window_id, status, updated_at DESC, id DESC); +CREATE INDEX idx_orders_buyer_context_updated_at + ON orders(buyer_context_key, updated_at DESC, id DESC) + WHERE buyer_context_key IS NOT NULL AND trim(buyer_context_key) <> ''; +CREATE INDEX idx_order_lines_order_sort + ON order_lines(order_id, sort_index, id); +CREATE INDEX idx_buyer_order_coordination_context_state_updated_at + ON buyer_order_coordination_records(buyer_context_key, state, updated_at); +CREATE INDEX idx_buyer_order_coordination_state_updated_at + ON buyer_order_coordination_records(state, updated_at); +CREATE INDEX idx_order_validation_receipts_order_time + ON order_validation_receipts(order_id, event_created_at DESC, event_id DESC) + WHERE order_id IS NOT NULL; +CREATE INDEX idx_order_validation_receipts_root_order + ON order_validation_receipts(root_event_id, raw_order_id); + +DROP TABLE order_validation_receipts_public_private_previous; +DROP TABLE order_lines_public_private_previous; +DROP TABLE buyer_order_coordination_records_public_private_previous; +DROP TABLE orders_public_private_previous; +DROP TABLE buyer_cart_lines_public_private_previous; +DROP TABLE buyer_carts_public_private_previous; diff --git a/crates/store/src/interop.rs b/crates/store/src/interop.rs @@ -2,18 +2,10 @@ use std::{fs, path::Path}; use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeParts, - ids::{RadrootsEventId, RadrootsOrderId, RadrootsPublicKey}, kinds::{ KIND_FARM as RADROOTS_KIND_FARM, KIND_LISTING as RADROOTS_KIND_LISTING, - KIND_ORDER_CANCELLATION as RADROOTS_KIND_ORDER_CANCELLATION, - KIND_ORDER_DECISION as RADROOTS_KIND_ORDER_DECISION, - KIND_ORDER_REQUEST as RADROOTS_KIND_ORDER_REQUEST, KIND_TRADE_VALIDATION_RECEIPT, + KIND_TRADE_VALIDATION_RECEIPT, }, - order::{RadrootsOrderEconomics, RadrootsOrderItem, RadrootsOrderRequest}, -}; -use radroots_event_codec::order::{ - order_cancellation_from_event, order_decision_from_event, order_event_context_from_tags, - order_request_from_event, }; use radroots_runtime_store::{ PublishOutboxStatus, RelayDeliveryEvidence, RelayDeliveryState, RuntimeStore, @@ -22,14 +14,8 @@ use radroots_runtime_store::{ use radroots_sql_core::{SqlExecutor, SqlxSqliteExecutor}; use radroots_studio_app_view::{ FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, - FulfillmentWindowId, OrderId, OrderStatus, PickupLocationId, ProductId, ProductStatus, - TradeAgreementStatus, TradeInventoryStatus, TradeProvenanceProjection, TradeRevisionStatus, + FulfillmentWindowId, PickupLocationId, ProductId, ProductStatus, TradeValidationReceiptProofSystem, TradeValidationReceiptResult, TradeValidationReceiptType, - TradeWorkflowProjection, TradeWorkflowSource, order_status_from_active_order_projection, -}; -use radroots_trade::order::{ - RadrootsOrderCancellationRecord, RadrootsOrderDecisionRecord, RadrootsOrderProjection, - RadrootsOrderReductionInputs, RadrootsOrderRequestRecord, reduce_order_events, }; use radroots_trade::validation_receipt::{ RadrootsTradeValidationReceipt, RadrootsValidationReceiptTags, validation_receipt_from_event, @@ -47,12 +33,7 @@ const RUNTIME_STORE_BATCH_LIMIT: u32 = 500; const APP_LOCAL_INTEROP_CURSOR_ID: &str = "radroots_studio_app_sqlite_projection_v1"; const KIND_FARM: i64 = RADROOTS_KIND_FARM as i64; const KIND_LISTING: i64 = RADROOTS_KIND_LISTING as i64; -const KIND_ORDER_REQUEST: i64 = RADROOTS_KIND_ORDER_REQUEST as i64; -const KIND_ORDER_DECISION: i64 = RADROOTS_KIND_ORDER_DECISION as i64; -const KIND_ORDER_CANCEL: i64 = RADROOTS_KIND_ORDER_CANCELLATION as i64; const KIND_VALIDATION_RECEIPT: i64 = KIND_TRADE_VALIDATION_RECEIPT as i64; -const ACTIVE_ORDER_EVENT_KINDS: [i64; 3] = - [KIND_ORDER_REQUEST, KIND_ORDER_DECISION, KIND_ORDER_CANCEL]; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct AppLocalInteropImportReport { @@ -292,69 +273,6 @@ impl<'a> AppLocalInteropRepository<'a> { Ok(events) } - fn load_signed_event_by_event_id( - &self, - event_id: &str, - ) -> Result<Option<RadrootsEventEnvelope>, AppSqliteError> { - let mut statement = self - .connection - .prepare( - "SELECT - event_id, - event_kind, - local_status, - outbox_status, - relay_delivery_json, - event_pubkey, - event_created_at, - event_tags_json, - event_content, - event_sig - FROM local_interop_imports - WHERE record_family = 'signed_event' - AND event_id = ?1 - ORDER BY local_seq DESC, record_id DESC", - ) - .map_err(|source| AppSqliteError::Query { - operation: "prepare local interop signed event id evidence query", - source, - })?; - let rows = statement - .query_map(crate::app_sqlite_params![event_id], |row| { - Ok(StoredLocalInteropSignedEventEvidence { - event_id: row.try_get(0)?, - event_kind: row.try_get(1)?, - local_status: row.try_get(2)?, - outbox_status: row.try_get(3)?, - relay_delivery_json: row.try_get(4)?, - event_pubkey: row.try_get(5)?, - event_created_at: row.try_get(6)?, - event_tags_json: row.try_get(7)?, - event_content: row.try_get(8)?, - event_sig: row.try_get(9)?, - }) - }) - .map_err(|source| AppSqliteError::Query { - operation: "query local interop signed event id evidence", - source, - })?; - - for row in rows { - let evidence = row.map_err(|source| AppSqliteError::Query { - operation: "read local interop signed event id evidence row", - source, - })?; - if !signed_event_local_interop_evidence_is_usable(&evidence) { - continue; - } - if let Some(event) = signed_event_from_local_interop_evidence(&evidence)? { - return Ok(Some(event)); - } - } - - Ok(None) - } - fn last_imported_change_seq(&self) -> Result<i64, AppSqliteError> { match self.connection.query_row( "SELECT last_change_seq @@ -747,7 +665,6 @@ impl<'a> AppLocalInteropRepository<'a> { Some(KIND_FARM) => self.import_signed_farm(record), Some(KIND_LISTING) => self.import_signed_listing(record), Some(KIND_VALIDATION_RECEIPT) => self.import_signed_validation_receipt(record), - Some(kind) if active_order_event_kind(kind) => self.import_signed_active_order(record), _ => Ok(Some(ProjectionRecord { kind: "signed_event", projected_id: record.event_id.clone(), @@ -1098,23 +1015,6 @@ impl<'a> AppLocalInteropRepository<'a> { Ok(Some(projection_record)) } - fn import_signed_active_order( - &self, - record: &RuntimeStoreRecord, - ) -> Result<Option<ProjectionRecord>, AppSqliteError> { - if !signed_event_record_is_usable(record) { - return Ok(Some(signed_event_projection(record))); - } - let Some(event) = signed_event_from_record(record)? else { - return Ok(Some(signed_event_projection(record))); - }; - let Some(current_evidence) = active_order_evidence_from_event(&event) else { - return Ok(Some(signed_event_projection(record))); - }; - self.project_active_order(record, current_evidence)?; - Ok(Some(signed_event_projection(record))) - } - fn import_signed_validation_receipt( &self, record: &RuntimeStoreRecord, @@ -1135,194 +1035,13 @@ impl<'a> AppLocalInteropRepository<'a> { })) } - fn project_active_order( - &self, - record: &RuntimeStoreRecord, - current_evidence: ActiveOrderEvidence, - ) -> Result<(), AppSqliteError> { - if let ActiveOrderEvidence::Request(request) = &current_evidence { - let order_id = - self.upsert_order_request(record, &request.payload, request.event_id.as_str())?; - self.attach_validation_receipts_for_request( - request.event_id.as_str(), - request.payload.order_id.as_str(), - order_id, - )?; - } - let mut evidence = self.load_active_order_evidence(current_evidence.root_event_id())?; - evidence.push(current_evidence); - dedupe_active_order_evidence(&mut evidence); - let Some((raw_order_id, buyer_pubkey, root_event_id)) = evidence - .first() - .map(ActiveOrderEvidence::order_projection_identity) - else { - return Ok(()); - }; - let raw_order_id = raw_order_id.to_owned(); - let buyer_pubkey = buyer_pubkey.to_owned(); - let root_event_id = root_event_id.to_owned(); - let order_id = projected_order_id( - raw_order_id.as_str(), - buyer_pubkey.as_str(), - root_event_id.as_str(), - ); - let buckets = ActiveOrderEvidenceBuckets::from_evidence(evidence); - let requests = buckets.requests.clone(); - let reducer_order_id = - raw_order_id - .parse::<RadrootsOrderId>() - .map_err(|_| AppSqliteError::DecodeId { - field: "order_id", - value: raw_order_id.clone(), - })?; - let projection = reduce_order_events( - &reducer_order_id, - RadrootsOrderReductionInputs { - requests: buckets.requests, - decisions: buckets.decisions, - cancellations: buckets.cancellations, - }, - ); - let request_payload = projection.request_event_id.as_deref().and_then(|event_id| { - requests - .iter() - .find(|request| request.event_id == event_id) - .map(|request| &request.payload) - }); - let revision = active_order_change_status(&projection); - let agreement_source = - request_payload.map(|request| active_order_agreement_source(request, &projection)); - self.apply_active_order_projection( - order_id, - &projection, - revision, - agreement_source.as_ref(), - ) - } - - fn upsert_order_request( - &self, - record: &RuntimeStoreRecord, - payload: &RadrootsOrderRequest, - root_event_id: &str, - ) -> Result<OrderId, AppSqliteError> { - let existing_listing = - self.existing_listing_projection(Some(payload.listing_addr.as_str()))?; - let farm_id = if let Some(existing_listing) = existing_listing.as_ref() { - existing_listing.farm_id - } else { - deterministic_farm_id( - Some(payload.seller_pubkey.as_str()), - payload.listing_addr.as_str(), - ) - }; - self.ensure_farm_exists(farm_id)?; - let order_id = projected_order_id( - payload.order_id.as_str(), - payload.buyer_pubkey.as_str(), - root_event_id, - ); - let order_number = existing_order_number(self.connection, order_id)? - .unwrap_or_else(|| deterministic_order_number(payload.order_id.as_str())); - self.connection - .execute( - "INSERT INTO orders ( - id, - farm_id, - fulfillment_window_id, - order_number, - customer_display_name, - status, - updated_at, - buyer_context_key, - buyer_email, - buyer_phone, - buyer_order_note - ) VALUES (?1, ?2, null, ?3, ?4, 'needs_action', strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), ?5, '', '', '') - ON CONFLICT(id) DO UPDATE SET - farm_id = excluded.farm_id, - order_number = excluded.order_number, - customer_display_name = excluded.customer_display_name, - status = excluded.status, - buyer_context_key = coalesce(orders.buyer_context_key, excluded.buyer_context_key), - updated_at = excluded.updated_at", - crate::app_sqlite_params![ - order_id.to_string(), - farm_id.to_string(), - order_number.as_str(), - order_customer_display_name(payload.buyer_pubkey.as_str()), - order_buyer_context_key(record, payload.buyer_pubkey.as_str()), - ], - ) - .map_err(|source| AppSqliteError::Query { - operation: "upsert local interop order request", - source, - })?; - self.replace_order_request_lines(order_id, payload, existing_listing.as_ref(), record)?; - Ok(order_id) - } - - fn apply_active_order_projection( - &self, - order_id: OrderId, - projection: &RadrootsOrderProjection, - revision: TradeRevisionStatus, - agreement_source: Option<&ActiveOrderAgreementSource>, - ) -> Result<(), AppSqliteError> { - let workflow = TradeWorkflowProjection::from_active_order_projection( - order_id, - projection, - revision, - TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::RuntimeStore), - ); - let Some(status) = order_status_from_active_order_projection(projection) else { - return Ok(()); - }; - self.connection - .execute( - "UPDATE orders - SET status = ?2, - workflow_revision = ?3, - workflow_agreement = ?4, - workflow_inventory = ?5, - workflow_provenance_source = ?6, - workflow_provenance_last_event_id = ?7, - updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - WHERE id = ?1", - crate::app_sqlite_params![ - workflow.order_id.to_string(), - status.storage_key(), - workflow.revision.storage_key(), - workflow.agreement.storage_key(), - workflow.inventory.storage_key(), - workflow.provenance.primary_source.storage_key(), - workflow.provenance.last_event_id.as_deref() - ], - ) - .map_err(|source| AppSqliteError::Query { - operation: "apply local interop active order projection", - source, - })?; - if projection.economics.is_some() - && let Some(agreement_source) = agreement_source - { - self.replace_active_order_agreement_lines(workflow.order_id, agreement_source)?; - } - self.apply_validation_receipt_workflow_projection(workflow.order_id)?; - Ok(()) - } - fn upsert_validation_receipt_projection( &self, event: &RadrootsEventEnvelope, receipt: &RadrootsTradeValidationReceipt, tags: &RadrootsValidationReceiptTags, ) -> Result<(), AppSqliteError> { - let order_id = match self.validation_receipt_order_attachment(tags)? { - ValidationReceiptOrderAttachment::Pending => None, - ValidationReceiptOrderAttachment::Attached(order_id) => Some(order_id), - ValidationReceiptOrderAttachment::Rejected => return Ok(()), - }; + let order_id: Option<String> = None; let result = TradeValidationReceiptResult::from_validation_receipt_result(receipt.result); let receipt_type = TradeValidationReceiptType::from_validation_receipt_type(receipt.receipt_type); @@ -1367,7 +1086,7 @@ impl<'a> AppLocalInteropRepository<'a> { event_created_at = excluded.event_created_at", crate::app_sqlite_params![ event.id_str(), - order_id.map(|order_id| order_id.to_string()), + order_id, tags.order_id.as_str(), tags.root_event_id.as_str(), tags.listing_event_id.as_str(), @@ -1385,459 +1104,73 @@ impl<'a> AppLocalInteropRepository<'a> { operation: "upsert local interop validation receipt projection", source, })?; - if let Some(order_id) = order_id { - self.apply_validation_receipt_workflow_projection(order_id)?; - } Ok(()) } - fn validation_receipt_order_attachment( - &self, - tags: &RadrootsValidationReceiptTags, - ) -> Result<ValidationReceiptOrderAttachment, AppSqliteError> { - let Some(root_event) = self.load_signed_event_by_event_id(tags.root_event_id.as_str())? - else { - return Ok(ValidationReceiptOrderAttachment::Pending); - }; - let Ok(envelope) = order_request_from_event(&root_event) else { - return Ok(ValidationReceiptOrderAttachment::Rejected); - }; - if envelope.payload.order_id != tags.order_id { - return Ok(ValidationReceiptOrderAttachment::Rejected); - } - - Ok(ValidationReceiptOrderAttachment::Attached( - projected_order_id( - envelope.payload.order_id.as_str(), - envelope.payload.buyer_pubkey.as_str(), - tags.root_event_id.as_str(), - ), - )) - } - - fn attach_validation_receipts_for_request( - &self, - root_event_id: &str, - raw_order_id: &str, - order_id: OrderId, - ) -> Result<(), AppSqliteError> { + fn upsert_farm_summary(&self, farm: &FarmSummary) -> Result<(), AppSqliteError> { self.connection .execute( - "UPDATE order_validation_receipts - SET order_id = ?3 - WHERE root_event_id = ?1 - AND raw_order_id = ?2 - AND order_id IS NULL", - crate::app_sqlite_params![root_event_id, raw_order_id, order_id.to_string()], + "INSERT INTO farms (id, display_name, readiness, created_at, updated_at) + VALUES (?1, ?2, ?3, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ON CONFLICT(id) DO UPDATE SET + display_name = excluded.display_name, + readiness = excluded.readiness, + updated_at = excluded.updated_at", + crate::app_sqlite_params![ + farm.farm_id.to_string(), + farm.display_name.as_str(), + farm_readiness_storage_key(farm.readiness), + ], ) .map_err(|source| AppSqliteError::Query { - operation: "attach local interop validation receipts to request", + operation: "upsert local interop farm summary", source, })?; - self.apply_validation_receipt_workflow_projection(order_id)?; Ok(()) } - fn apply_validation_receipt_workflow_projection( - &self, - order_id: OrderId, - ) -> Result<(), AppSqliteError> { - let current: Option<(String, String, Option<String>)> = self - .connection - .query_row( - "SELECT status, workflow_agreement, workflow_provenance_last_event_id - FROM orders - WHERE id = ?1", - crate::app_sqlite_params![order_id.to_string()], - |row| Ok((row.try_get(0)?, row.try_get(1)?, row.try_get(2)?)), - ) - .optional() - .map_err(|source| AppSqliteError::Query { - operation: "load order workflow before validation receipt projection", - source, - })?; - let Some((status, agreement, Some(target_event_id))) = current else { - return Ok(()); - }; - if !matches!( - agreement.as_str(), - value if value == TradeAgreementStatus::AgreedPendingValidation.storage_key() - || value == TradeAgreementStatus::Committed.storage_key() - || value == TradeAgreementStatus::Invalid.storage_key() - ) { - return Ok(()); - } - if !matches!( - status.as_str(), - value if value == OrderStatus::NeedsAction.storage_key() - || value == OrderStatus::Scheduled.storage_key() - || value == OrderStatus::NeedsReview.storage_key() - ) { - return Ok(()); - } - let latest_receipt: Option<String> = self - .connection - .query_row( - "SELECT result - FROM order_validation_receipts - WHERE order_id = ?1 - AND receipt_type = 'trade_transition' - AND target_event_id = ?2 - ORDER BY event_created_at DESC, event_id DESC - LIMIT 1", - crate::app_sqlite_params![order_id.to_string(), target_event_id.as_str()], - |row| row.try_get(0), - ) - .optional() - .map_err(|source| AppSqliteError::Query { - operation: "load latest validation receipt workflow projection", - source, - })?; - let Some(result) = latest_receipt else { - return Ok(()); - }; - let (status, agreement, inventory) = match result.as_str() { - "valid" => ( - OrderStatus::NeedsAction.storage_key(), - TradeAgreementStatus::AgreedPendingValidation.storage_key(), - TradeInventoryStatus::Reserved.storage_key(), - ), - "needs_review" => ( - OrderStatus::NeedsReview.storage_key(), - TradeAgreementStatus::Invalid.storage_key(), - TradeInventoryStatus::NeedsReview.storage_key(), - ), - _ => { - return Err(AppSqliteError::DecodeEnum { - field: "order_validation_receipts.result", - value: result, - }); - } - }; + fn upsert_local_work_farm_summary(&self, farm: &FarmSummary) -> Result<(), AppSqliteError> { self.connection .execute( - "UPDATE orders - SET status = ?2, - workflow_agreement = ?3, - workflow_inventory = ?4, - workflow_provenance_source = ?5, - workflow_provenance_last_event_id = ?6, - updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - WHERE id = ?1", + "INSERT INTO farms (id, display_name, readiness, created_at, updated_at) + VALUES (?1, ?2, ?3, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ON CONFLICT(id) DO UPDATE SET + display_name = excluded.display_name, + readiness = CASE + WHEN farms.readiness = 'ready' AND excluded.readiness = 'incomplete' + THEN farms.readiness + ELSE excluded.readiness + END, + updated_at = excluded.updated_at", crate::app_sqlite_params![ - order_id.to_string(), - status, - agreement, - inventory, - TradeWorkflowSource::RuntimeStore.storage_key(), - target_event_id, + farm.farm_id.to_string(), + farm.display_name.as_str(), + farm_readiness_storage_key(farm.readiness), ], ) .map_err(|source| AppSqliteError::Query { - operation: "apply validation receipt workflow projection", + operation: "upsert local interop local work farm summary", source, })?; Ok(()) } - fn load_active_order_evidence( - &self, - root_event_id: &str, - ) -> Result<Vec<ActiveOrderEvidence>, AppSqliteError> { - let mut evidence = Vec::new(); - for kind in ACTIVE_ORDER_EVENT_KINDS { - for event in self.load_signed_events_by_kind(kind)? { - let Some(record) = active_order_evidence_from_event(&event) else { - continue; - }; - if record.root_event_id() == root_event_id { - evidence.push(record); - } - } - } - Ok(evidence) - } - - fn replace_order_request_lines( + fn mark_farm_buyer_visible( &self, - order_id: OrderId, - payload: &RadrootsOrderRequest, - existing_listing: Option<&ExistingListingProjection>, + farm_id: FarmId, record: &RuntimeStoreRecord, + method: FarmOrderMethod, ) -> Result<(), AppSqliteError> { self.connection .execute( - "DELETE FROM order_lines WHERE order_id = ?1", - crate::app_sqlite_params![order_id.to_string()], + "UPDATE farms + SET readiness = 'ready', + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = ?1", + [farm_id.to_string()], ) .map_err(|source| AppSqliteError::Query { - operation: "replace local interop order lines", - source, - })?; - for (index, item) in payload.items.iter().enumerate() { - let economics_item = payload - .economics - .items - .iter() - .find(|candidate| candidate.bin_id == item.bin_id); - let unit_label = economics_item - .map(|item| item.quantity_unit.to_string()) - .or_else(|| existing_listing.map(|listing| listing.unit_label.clone())) - .unwrap_or_else(|| "item".to_owned()); - let unit_price_minor_units = economics_item.and_then(|item| { - parse_decimal_minor_units(item.unit_price_amount.to_string().as_str()) - }); - let price_currency = economics_item - .map(|item| item.unit_price_currency.to_string()) - .unwrap_or_else(|| payload.economics.currency.to_string()); - let title = existing_listing - .map(|listing| listing.title.clone()) - .unwrap_or_else(|| item.bin_id.to_string()); - self.connection - .execute( - "INSERT INTO order_lines ( - id, - order_id, - title, - quantity_value, - quantity_unit_label, - quantity_display, - listing_bin_id, - unit_price_minor_units, - price_currency, - farm_key, - listing_addr, - listing_event_id, - listing_relays_json, - seller_pubkey, - sort_index - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, null, ?13, ?14)", - crate::app_sqlite_params![ - format!( - "{}:{}", - order_id, - order_line_product_id(payload, existing_listing, item) - ), - order_id.to_string(), - title.as_str(), - i64::from(item.bin_count), - unit_label.as_str(), - format_quantity_display(item.bin_count, unit_label.as_str()), - item.bin_id.as_str(), - unit_price_minor_units, - price_currency.as_str(), - existing_listing.and_then(|listing| listing.farm_key.as_deref()), - payload.listing_addr.as_str(), - listing_event_id_from_order_record(record).as_deref(), - payload.seller_pubkey.as_str(), - index as i64, - ], - ) - .map_err(|source| AppSqliteError::Query { - operation: "insert local interop order line", - source, - })?; - } - Ok(()) - } - - fn replace_active_order_agreement_lines( - &self, - order_id: OrderId, - source: &ActiveOrderAgreementSource, - ) -> Result<(), AppSqliteError> { - let existing_listing = - self.existing_listing_projection(Some(source.listing_addr.as_str()))?; - let metadata = self.existing_order_line_metadata(order_id)?; - self.connection - .execute( - "DELETE FROM order_lines WHERE order_id = ?1", - crate::app_sqlite_params![order_id.to_string()], - ) - .map_err(|source| AppSqliteError::Query { - operation: "replace local interop active order agreement lines", - source, - })?; - for (index, item) in source.items.iter().enumerate() { - let economics_item = source - .economics - .items - .iter() - .find(|candidate| candidate.bin_id == item.bin_id); - let unit_label = economics_item - .map(|item| item.quantity_unit.to_string()) - .or_else(|| { - existing_listing - .as_ref() - .map(|listing| listing.unit_label.clone()) - }) - .unwrap_or_else(|| "item".to_owned()); - let unit_price_minor_units = economics_item.and_then(|item| { - parse_decimal_minor_units(item.unit_price_amount.to_string().as_str()) - }); - let price_currency = economics_item - .map(|item| item.unit_price_currency.to_string()) - .unwrap_or_else(|| source.economics.currency.to_string()); - let title = existing_listing - .as_ref() - .filter(|listing| { - listing - .listing_bin_id - .as_deref() - .is_none_or(|listing_bin_id| listing_bin_id == item.bin_id) - }) - .map(|listing| listing.title.clone()) - .unwrap_or_else(|| item.bin_id.to_string()); - self.connection - .execute( - "INSERT INTO order_lines ( - id, - order_id, - title, - quantity_value, - quantity_unit_label, - quantity_display, - listing_bin_id, - unit_price_minor_units, - price_currency, - farm_key, - listing_addr, - listing_event_id, - listing_relays_json, - seller_pubkey, - sort_index - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", - crate::app_sqlite_params![ - format!( - "{}:{}", - order_id, - order_agreement_line_product_id( - source.listing_addr.as_str(), - source.seller_pubkey.as_str(), - existing_listing.as_ref(), - item, - ) - ), - order_id.to_string(), - title.as_str(), - i64::from(item.bin_count), - unit_label.as_str(), - format_quantity_display(item.bin_count, unit_label.as_str()), - item.bin_id.as_str(), - unit_price_minor_units, - price_currency.as_str(), - existing_listing - .as_ref() - .and_then(|listing| listing.farm_key.as_deref()), - source.listing_addr.as_str(), - metadata - .as_ref() - .and_then(|metadata| metadata.listing_event_id.as_deref()), - metadata - .as_ref() - .and_then(|metadata| metadata.listing_relays_json.as_deref()), - source.seller_pubkey.as_str(), - index as i64, - ], - ) - .map_err(|source| AppSqliteError::Query { - operation: "insert local interop active order agreement line", - source, - })?; - } - Ok(()) - } - - fn existing_order_line_metadata( - &self, - order_id: OrderId, - ) -> Result<Option<ExistingOrderLineMetadata>, AppSqliteError> { - self.connection - .query_row( - "SELECT listing_event_id, listing_relays_json - FROM order_lines - WHERE order_id = ?1 - ORDER BY sort_index ASC, id ASC - LIMIT 1", - crate::app_sqlite_params![order_id.to_string()], - |row| { - Ok(ExistingOrderLineMetadata { - listing_event_id: row.try_get::<Option<String>, _>(0)?, - listing_relays_json: row.try_get::<Option<String>, _>(1)?, - }) - }, - ) - .optional() - .map_err(|source| AppSqliteError::Query { - operation: "load existing local interop order line metadata", - source, - }) - } - - fn upsert_farm_summary(&self, farm: &FarmSummary) -> Result<(), AppSqliteError> { - self.connection - .execute( - "INSERT INTO farms (id, display_name, readiness, created_at, updated_at) - VALUES (?1, ?2, ?3, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) - ON CONFLICT(id) DO UPDATE SET - display_name = excluded.display_name, - readiness = excluded.readiness, - updated_at = excluded.updated_at", - crate::app_sqlite_params![ - farm.farm_id.to_string(), - farm.display_name.as_str(), - farm_readiness_storage_key(farm.readiness), - ], - ) - .map_err(|source| AppSqliteError::Query { - operation: "upsert local interop farm summary", - source, - })?; - Ok(()) - } - - fn upsert_local_work_farm_summary(&self, farm: &FarmSummary) -> Result<(), AppSqliteError> { - self.connection - .execute( - "INSERT INTO farms (id, display_name, readiness, created_at, updated_at) - VALUES (?1, ?2, ?3, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) - ON CONFLICT(id) DO UPDATE SET - display_name = excluded.display_name, - readiness = CASE - WHEN farms.readiness = 'ready' AND excluded.readiness = 'incomplete' - THEN farms.readiness - ELSE excluded.readiness - END, - updated_at = excluded.updated_at", - crate::app_sqlite_params![ - farm.farm_id.to_string(), - farm.display_name.as_str(), - farm_readiness_storage_key(farm.readiness), - ], - ) - .map_err(|source| AppSqliteError::Query { - operation: "upsert local interop local work farm summary", - source, - })?; - Ok(()) - } - - fn mark_farm_buyer_visible( - &self, - farm_id: FarmId, - record: &RuntimeStoreRecord, - method: FarmOrderMethod, - ) -> Result<(), AppSqliteError> { - self.connection - .execute( - "UPDATE farms - SET readiness = 'ready', - updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - WHERE id = ?1", - [farm_id.to_string()], - ) - .map_err(|source| AppSqliteError::Query { - operation: "mark local interop farm buyer visible", + operation: "mark local interop farm buyer visible", source, })?; let Some(account_id) = record @@ -2609,13 +1942,6 @@ enum DuplicateSignedEventAction { Skip, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ValidationReceiptOrderAttachment { - Pending, - Attached(OrderId), - Rejected, -} - #[derive(Clone, Debug, Eq, PartialEq)] struct ProjectionRecord { kind: &'static str, @@ -2767,86 +2093,6 @@ struct ExistingListingProjection { farm_key: Option<String>, } -#[derive(Clone, Debug, Eq, PartialEq)] -struct ActiveOrderAgreementSource { - listing_addr: String, - seller_pubkey: String, - items: Vec<RadrootsOrderItem>, - economics: RadrootsOrderEconomics, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ExistingOrderLineMetadata { - listing_event_id: Option<String>, - listing_relays_json: Option<String>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum ActiveOrderEvidence { - Request(RadrootsOrderRequestRecord), - Decision(RadrootsOrderDecisionRecord), - Cancellation(RadrootsOrderCancellationRecord), -} - -impl ActiveOrderEvidence { - fn event_id(&self) -> &str { - match self { - Self::Request(record) => record.event_id.as_str(), - Self::Decision(record) => record.event_id.as_str(), - Self::Cancellation(record) => record.event_id.as_str(), - } - } - - fn root_event_id(&self) -> &str { - match self { - Self::Request(record) => record.event_id.as_str(), - Self::Decision(record) => record.root_event_id.as_str(), - Self::Cancellation(record) => record.root_event_id.as_str(), - } - } - - fn order_projection_identity(&self) -> (&str, &str, &str) { - match self { - Self::Request(record) => ( - record.payload.order_id.as_str(), - record.payload.buyer_pubkey.as_str(), - record.event_id.as_str(), - ), - Self::Decision(record) => ( - record.payload.order_id.as_str(), - record.payload.buyer_pubkey.as_str(), - record.root_event_id.as_str(), - ), - Self::Cancellation(record) => ( - record.payload.order_id.as_str(), - record.payload.buyer_pubkey.as_str(), - record.root_event_id.as_str(), - ), - } - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -struct ActiveOrderEvidenceBuckets { - requests: Vec<RadrootsOrderRequestRecord>, - decisions: Vec<RadrootsOrderDecisionRecord>, - cancellations: Vec<RadrootsOrderCancellationRecord>, -} - -impl ActiveOrderEvidenceBuckets { - fn from_evidence(evidence: Vec<ActiveOrderEvidence>) -> Self { - let mut buckets = Self::default(); - for record in evidence { - match record { - ActiveOrderEvidence::Request(record) => buckets.requests.push(record), - ActiveOrderEvidence::Decision(record) => buckets.decisions.push(record), - ActiveOrderEvidence::Cancellation(record) => buckets.cancellations.push(record), - } - } - buckets - } -} - fn listing_currentness_row( row: &sqlx::sqlite::SqliteRow, ) -> Result<StoredListingCurrentnessEvidence, sqlx::Error> { @@ -2991,65 +2237,6 @@ fn parse_app_d_tag_uuid(value: &str) -> Option<Uuid> { } } -fn active_order_event_kind(kind: i64) -> bool { - ACTIVE_ORDER_EVENT_KINDS.contains(&kind) -} - -fn active_event_id(event: &RadrootsEventEnvelope) -> Option<RadrootsEventId> { - Some(event.id().clone()) -} - -fn active_author_pubkey(event: &RadrootsEventEnvelope) -> Option<RadrootsPublicKey> { - Some(event.author().clone()) -} - -fn active_order_evidence_from_event(event: &RadrootsEventEnvelope) -> Option<ActiveOrderEvidence> { - match i64::from(event.kind_u32()) { - KIND_ORDER_REQUEST => { - let envelope = order_request_from_event(event).ok()?; - Some(ActiveOrderEvidence::Request(RadrootsOrderRequestRecord { - event_id: active_event_id(event)?, - author_pubkey: active_author_pubkey(event)?, - payload: envelope.payload, - })) - } - KIND_ORDER_DECISION => { - let envelope = order_decision_from_event(event).ok()?; - let tags = event.tags_as_vec(); - let context = order_event_context_from_tags(envelope.message_type, &tags).ok()?; - Some(ActiveOrderEvidence::Decision(RadrootsOrderDecisionRecord { - event_id: active_event_id(event)?, - author_pubkey: active_author_pubkey(event)?, - counterparty_pubkey: context.counterparty_pubkey, - root_event_id: context.root_event_id?, - prev_event_id: context.prev_event_id?, - payload: envelope.payload, - })) - } - KIND_ORDER_CANCEL => { - let envelope = order_cancellation_from_event(event).ok()?; - let tags = event.tags_as_vec(); - let context = order_event_context_from_tags(envelope.message_type, &tags).ok()?; - Some(ActiveOrderEvidence::Cancellation( - RadrootsOrderCancellationRecord { - event_id: active_event_id(event)?, - author_pubkey: active_author_pubkey(event)?, - counterparty_pubkey: context.counterparty_pubkey, - root_event_id: context.root_event_id?, - prev_event_id: context.prev_event_id?, - payload: envelope.payload, - }, - )) - } - _ => None, - } -} - -fn dedupe_active_order_evidence(evidence: &mut Vec<ActiveOrderEvidence>) { - evidence.sort_by(|left, right| left.event_id().cmp(right.event_id())); - evidence.dedup_by(|left, right| left.event_id() == right.event_id()); -} - fn signed_event_projection(record: &RuntimeStoreRecord) -> ProjectionRecord { ProjectionRecord { kind: "signed_event", @@ -3227,143 +2414,6 @@ fn tags_from_json(value: &Value) -> Option<Vec<Vec<String>>> { }) } -pub fn projected_order_id_from_trade_request( - order_id: &str, - buyer_pubkey: &str, - root_event_id: &str, -) -> OrderId { - OrderId::from(deterministic_uuid( - "radroots-cli-order-root", - Some(buyer_pubkey), - format!("{}:{}", root_event_id.trim(), order_id.trim()).as_str(), - )) -} - -fn projected_order_id(order_id: &str, buyer_pubkey: &str, root_event_id: &str) -> OrderId { - projected_order_id_from_trade_request(order_id, buyer_pubkey, root_event_id) -} - -fn active_order_change_status(_projection: &RadrootsOrderProjection) -> TradeRevisionStatus { - TradeRevisionStatus::None -} - -fn active_order_agreement_source( - request: &RadrootsOrderRequest, - _projection: &RadrootsOrderProjection, -) -> ActiveOrderAgreementSource { - ActiveOrderAgreementSource { - listing_addr: request.listing_addr.to_string(), - seller_pubkey: request.seller_pubkey.to_string(), - items: request.items.clone(), - economics: request.economics.clone(), - } -} - -fn order_line_product_id( - payload: &RadrootsOrderRequest, - existing_listing: Option<&ExistingListingProjection>, - item: &radroots_event::order::RadrootsOrderItem, -) -> ProductId { - order_agreement_line_product_id( - payload.listing_addr.as_str(), - payload.seller_pubkey.as_str(), - existing_listing, - item, - ) -} - -fn order_agreement_line_product_id( - listing_addr: &str, - seller_pubkey: &str, - existing_listing: Option<&ExistingListingProjection>, - item: &RadrootsOrderItem, -) -> ProductId { - if let Some(existing_listing) = existing_listing - && existing_listing - .listing_bin_id - .as_deref() - .is_none_or(|listing_bin_id| listing_bin_id == item.bin_id) - { - return existing_listing.product_id; - } - let product_key = format!("{listing_addr}:{}", item.bin_id); - deterministic_product_id(Some(seller_pubkey), product_key.as_str()) -} - -fn deterministic_order_number(order_id: &str) -> String { - let trimmed = order_id.trim(); - let suffix = trimmed - .chars() - .filter(|ch| ch.is_ascii_alphanumeric()) - .take(8) - .collect::<String>(); - if suffix.is_empty() { - "R-RELAY".to_owned() - } else { - format!("R-{suffix}") - } -} - -fn existing_order_number( - connection: &AppSqliteDatabase, - order_id: OrderId, -) -> Result<Option<String>, AppSqliteError> { - connection - .query_row( - "SELECT order_number FROM orders WHERE id = ?1 LIMIT 1", - crate::app_sqlite_params![order_id.to_string()], - |row| row.try_get::<String, _>(0), - ) - .optional() - .map_err(|source| AppSqliteError::Query { - operation: "load existing local interop order number", - source, - }) -} - -fn order_customer_display_name(buyer_pubkey: &str) -> String { - let prefix = buyer_pubkey.trim().chars().take(12).collect::<String>(); - if prefix.is_empty() { - "Relay buyer".to_owned() - } else { - format!("Relay buyer {prefix}") - } -} - -fn order_buyer_context_key(record: &RuntimeStoreRecord, buyer_pubkey: &str) -> String { - if record.source_runtime == SourceRuntime::App - && record - .event_pubkey - .as_deref() - .map(str::trim) - .is_some_and(|event_pubkey| event_pubkey == buyer_pubkey.trim()) - && let Some(owner_account_id) = record - .owner_account_id - .as_deref() - .map(str::trim) - .filter(|owner_account_id| !owner_account_id.is_empty()) - { - return format!("account:{owner_account_id}"); - } - format!("nostr:{}", buyer_pubkey.trim()) -} - -fn format_quantity_display(quantity: u32, unit_label: &str) -> String { - let unit_label = unit_label.trim(); - if unit_label.is_empty() { - quantity.to_string() - } else { - format!("{quantity} {unit_label}") - } -} - -fn listing_event_id_from_order_record(record: &RuntimeStoreRecord) -> Option<String> { - record - .event_tags_json - .as_ref() - .and_then(|tags| tag_index_value(Some(tags), "listing_event", 1)) -} - fn base64_url_digit(byte: u8) -> Option<u8> { match byte { b'A'..=b'Z' => Some(byte - b'A'), @@ -3733,36 +2783,18 @@ fn farm_readiness_from_storage_key(readiness: &str) -> Result<FarmReadiness, App mod tests { use std::collections::BTreeSet; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, - }; use radroots_event::{ - RadrootsEventEnvelope, RadrootsEventEnvelopeParts, RadrootsEventPtr, - ids::{ - RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, - RadrootsOrderQuoteId, RadrootsPublicKey, - }, - order::{ - RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, - RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, RadrootsOrderEconomics, - RadrootsOrderInventoryCommitment, RadrootsOrderItem, RadrootsOrderPricingBasis, - RadrootsOrderRequest, - }, + RadrootsEventEnvelope, RadrootsEventEnvelopeParts, ids::RadrootsEventId, wire::RadrootsNip01EventWireParts as WireEventParts, }; - use radroots_event_codec::order::{ - order_cancellation_event_build, order_decision_event_build, order_request_event_build, - }; use radroots_runtime_store::{ PublishOutboxStatus, RelayDeliveryEvidence, RuntimeStore, RuntimeStoreRecordFamily, RuntimeStoreRecordInput, RuntimeStoreRecordStatus, RuntimeStoreRecordUpdate, SourceRuntime, }; use radroots_sql_core::SqlxSqliteExecutor; use radroots_studio_app_view::{ - BuyerContext, BuyerOrderStatus, FarmId, FarmOrderMethod, OrderId, OrderStatus, - OrdersFilter, OrdersScreenQueryState, ProductAvailabilityState, ProductId, - TradeAgreementStatus, TradeInventoryStatus, TradeValidationReceiptProofSystem, - TradeValidationReceiptResult, TradeValidationReceiptType, TradeWorkflowSource, + BuyerContext, FarmId, FarmOrderMethod, ProductAvailabilityState, ProductId, + TradeValidationReceiptResult, }; use radroots_trade::validation_receipt::{ RadrootsTradeValidationReceipt, RadrootsValidationReceiptProof, @@ -3775,9 +2807,8 @@ mod tests { use uuid::Uuid; use super::{ - KIND_FARM, KIND_LISTING, KIND_ORDER_REQUEST, KIND_VALIDATION_RECEIPT, - deterministic_farm_id, deterministic_product_id, projected_farm_id, projected_order_id, - projected_product_id, + KIND_FARM, KIND_LISTING, KIND_VALIDATION_RECEIPT, deterministic_farm_id, + deterministic_product_id, projected_farm_id, projected_product_id, }; use crate::{AppSqliteStore, BuyerRepeatDemandApplyOutcome, DatabaseTarget}; @@ -4231,14 +3262,6 @@ mod tests { .expect("seed origin product"); } - fn decimal(raw: &str) -> RadrootsCoreDecimal { - raw.parse().expect("valid decimal") - } - - fn usd(raw: &str) -> RadrootsCoreMoney { - RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD) - } - fn test_pubkey(seed: &str) -> String { let left = Uuid::new_v5(&Uuid::NAMESPACE_URL, seed.as_bytes()); let right_seed = format!("{seed}:right"); @@ -4250,332 +3273,23 @@ mod tests { test_pubkey(seed) } - fn typed_order_id(raw: &str) -> RadrootsOrderId { - raw.parse().expect("valid order id") - } - - fn typed_quote_id(raw: &str) -> RadrootsOrderQuoteId { - raw.parse().expect("valid quote id") - } - - fn typed_bin_id(raw: &str) -> RadrootsInventoryBinId { - raw.parse().expect("valid bin id") - } - - fn typed_event_id(raw: &str) -> RadrootsEventId { - raw.parse().expect("valid event id") - } - - fn typed_pubkey(raw: &str) -> RadrootsPublicKey { - raw.parse().expect("valid pubkey") - } - - fn typed_listing_addr(raw: &str) -> RadrootsListingAddress { - raw.parse().expect("valid listing address") - } - - fn listing_event_ptr(event_id: &str) -> RadrootsEventPtr { - RadrootsEventPtr { - id: test_event_id_seed(event_id), - relays: Some("ws://127.0.0.1:1234/".to_owned()), - } + fn hex_event_id(seed: u8) -> String { + format!("{seed:064x}") } - fn order_request_payload( - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - ) -> RadrootsOrderRequest { - RadrootsOrderRequest { - order_id: typed_order_id(order_id), - listing_addr: typed_listing_addr(listing_addr), - buyer_pubkey: typed_pubkey(buyer_pubkey), - seller_pubkey: typed_pubkey(seller_pubkey), - items: vec![RadrootsOrderItem { - bin_id: typed_bin_id("bin-1"), - bin_count: 2, - }], - economics: RadrootsOrderEconomics { - quote_id: typed_quote_id(format!("quote-{order_id}").as_str()), - quote_version: 1, - pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency: RadrootsCoreCurrency::USD, - items: vec![RadrootsOrderEconomicItem { - bin_id: typed_bin_id("bin-1"), - bin_count: 2, - quantity_amount: decimal("1"), - quantity_unit: RadrootsCoreUnit::Each, - unit_price_amount: decimal("8"), - unit_price_currency: RadrootsCoreCurrency::USD, - line_subtotal: usd("16"), - }], - discounts: Vec::<RadrootsOrderEconomicLine>::new(), - adjustments: Vec::<RadrootsOrderEconomicLine>::new(), - subtotal: usd("16"), - discount_total: usd("0"), - adjustment_total: usd("0"), - total: usd("16"), - }, - } + fn test_event_signature_seed(seed: &str) -> String { + let base = test_event_id_seed(seed); + format!("{base}{base}") } - fn accepted_order_decision_payload( - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - ) -> RadrootsOrderDecision { - RadrootsOrderDecision { - order_id: typed_order_id(order_id), - listing_addr: typed_listing_addr(listing_addr), - buyer_pubkey: typed_pubkey(buyer_pubkey), - seller_pubkey: typed_pubkey(seller_pubkey), - decision: RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: typed_bin_id("bin-1"), - bin_count: 2, - }], - }, - } + fn hash32(seed: u8) -> String { + format!("0x{seed:064x}") } - fn declined_order_decision_payload( - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - ) -> RadrootsOrderDecision { - RadrootsOrderDecision { - order_id: typed_order_id(order_id), - listing_addr: typed_listing_addr(listing_addr), - buyer_pubkey: typed_pubkey(buyer_pubkey), - seller_pubkey: typed_pubkey(seller_pubkey), - decision: RadrootsOrderDecisionOutcome::Declined { - reason: "not available for this pickup".to_owned(), - }, - } - } - - fn order_cancel_payload( - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - ) -> RadrootsOrderCancellation { - RadrootsOrderCancellation { - order_id: typed_order_id(order_id), - listing_addr: typed_listing_addr(listing_addr), - buyer_pubkey: typed_pubkey(buyer_pubkey), - seller_pubkey: typed_pubkey(seller_pubkey), - reason: "buyer changed pickup plan".to_owned(), - } - } - - struct ValidationReceiptOrderFixture { - app_store: AppSqliteStore, - events: RuntimeStore<SqlxSqliteExecutor>, - buyer_context: BuyerContext, - seller_farm_id: FarmId, - order_id: OrderId, - order_id_raw: String, - listing_addr: String, - buyer_pubkey: String, - seller_pubkey: String, - listing_event_id: String, - request_event_id: String, - decision_event_id: String, - } - - fn validation_receipt_order_fixture(label: &str) -> ValidationReceiptOrderFixture { - let app_store = - AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); - let events = runtime_store_store(); - let farm_key = "DDDDDDDDDDDDDDDDDDDDDD"; - let listing_key = "AAAAAAAAAAAAAAAAAAAAAw"; - let seller_pubkey = test_pubkey(format!("{label}-seller").as_str()); - let buyer_pubkey = test_pubkey(format!("{label}-buyer").as_str()); - let order_id_raw = format!("{label}-order"); - let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); - let listing_event_id = hex_event_id(21); - let request_event_id = hex_event_id(22); - let decision_event_id = hex_event_id(23); - events - .append_record(&signed_market_listing_record( - format!("{label}-listing-record").as_str(), - seller_pubkey.as_str(), - farm_key, - listing_key, - "Validation Eggs", - "9", - "active", - "pickup", - "North barn pickup", - 4_102_444_800, - 4_102_531_200, - RuntimeStoreRecordStatus::Published, - PublishOutboxStatus::Acknowledged, - )) - .expect("append signed listing"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import signed listing"); - - let request_payload = order_request_payload( - order_id_raw.as_str(), - listing_addr.as_str(), - buyer_pubkey.as_str(), - seller_pubkey.as_str(), - ); - let request_parts = - order_request_event_build(&listing_event_ptr(&listing_event_id), &request_payload) - .expect("build validation order request"); - let request_event = event_from_parts_at( - request_event_id.as_str(), - buyer_pubkey.as_str(), - request_parts, - 1_777_665_601, - ); - events - .append_record(&signed_order_event_record( - format!("app:signed_event:{label}:request").as_str(), - &request_event, - listing_addr.as_str(), - SourceRuntime::App, - Some("acct_validation"), - )) - .expect("append validation order request"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import validation order request"); - - let decision_payload = accepted_order_decision_payload( - order_id_raw.as_str(), - listing_addr.as_str(), - buyer_pubkey.as_str(), - seller_pubkey.as_str(), - ); - let decision_parts = order_decision_event_build( - &typed_event_id(request_event_id.as_str()), - &typed_event_id(request_event_id.as_str()), - &decision_payload, - ) - .expect("build validation order decision"); - let decision_event = event_from_parts_at( - decision_event_id.as_str(), - seller_pubkey.as_str(), - decision_parts, - 1_777_665_602, - ); - events - .append_record(&signed_order_event_record( - format!("cli:signed_event:{label}:decision").as_str(), - &decision_event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - )) - .expect("append validation order decision"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import validation order decision"); - - ValidationReceiptOrderFixture { - app_store, - events, - buyer_context: BuyerContext::account("acct_validation"), - seller_farm_id: deterministic_farm_id(Some(seller_pubkey.as_str()), farm_key), - order_id: projected_order_id( - order_id_raw.as_str(), - buyer_pubkey.as_str(), - request_event_id.as_str(), - ), - order_id_raw, - listing_addr, - buyer_pubkey, - seller_pubkey, - listing_event_id, - request_event_id, - decision_event_id, - } - } - - fn event_from_parts( - event_id: &str, - author: &str, - parts: WireEventParts, - ) -> RadrootsEventEnvelope { - event_from_parts_at(event_id, author, parts, 1_777_665_600) - } - - fn event_from_parts_at( - event_id: &str, - author: &str, - parts: WireEventParts, - created_at: u32, - ) -> RadrootsEventEnvelope { - let event_id = event_id - .parse::<RadrootsEventId>() - .map(|event_id| event_id.to_string()) - .unwrap_or_else(|_| test_event_id_seed(event_id)); - RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - sig: test_event_signature_seed(event_id.as_str()), - id: event_id, - author: author.to_owned(), - created_at: u64::from(created_at), - kind: parts.kind, - tags: parts.tags, - content: parts.content, - }) - .expect("test event envelope") - } - - fn event_with_tags( - event: &RadrootsEventEnvelope, - tags: Vec<Vec<String>>, - ) -> RadrootsEventEnvelope { - RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - sig: event.sig_str().to_owned(), - id: event.id_str().to_owned(), - author: event.author_str().to_owned(), - created_at: event.created_at_u64(), - kind: event.kind_u32(), - tags, - content: event.content().to_owned(), - }) - .expect("test event envelope with tags") - } - - fn event_with_kind(event: &RadrootsEventEnvelope, kind: u32) -> RadrootsEventEnvelope { - RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - sig: event.sig_str().to_owned(), - id: event.id_str().to_owned(), - author: event.author_str().to_owned(), - created_at: event.created_at_u64(), - kind, - tags: event.tags_as_vec(), - content: event.content().to_owned(), - }) - .expect("test event envelope with kind") - } - - fn hex_event_id(seed: u8) -> String { - format!("{seed:064x}") - } - - fn test_event_signature_seed(seed: &str) -> String { - let base = test_event_id_seed(seed); - format!("{base}{base}") - } - - fn hash32(seed: u8) -> String { - format!("0x{seed:064x}") - } - - fn validation_error_bitmap(result: RadrootsValidationReceiptResult) -> String { - match result { - RadrootsValidationReceiptResult::Valid => format!("0x{:032x}", 0), - RadrootsValidationReceiptResult::Invalid => format!("0x{:032x}", 1), + fn validation_error_bitmap(result: RadrootsValidationReceiptResult) -> String { + match result { + RadrootsValidationReceiptResult::Valid => format!("0x{:032x}", 0), + RadrootsValidationReceiptResult::Invalid => format!("0x{:032x}", 1), } } @@ -4620,6 +3334,28 @@ mod tests { } } + fn event_from_parts_at( + event_id: &str, + author: &str, + parts: WireEventParts, + created_at: u32, + ) -> RadrootsEventEnvelope { + let event_id = event_id + .parse::<RadrootsEventId>() + .map(|event_id| event_id.to_string()) + .unwrap_or_else(|_| test_event_id_seed(event_id)); + RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + sig: test_event_signature_seed(event_id.as_str()), + id: event_id, + author: author.to_owned(), + created_at: u64::from(created_at), + kind: parts.kind, + tags: parts.tags, + content: parts.content, + }) + .expect("test event envelope") + } + #[expect(clippy::too_many_arguments)] fn validation_receipt_event( event_id: &str, @@ -4638,10 +3374,9 @@ mod tests { event_from_parts_at(event_id, author, parts, created_at) } - fn signed_order_event_record( + fn signed_receipt_event_record( record_id: &str, event: &RadrootsEventEnvelope, - listing_addr: &str, source_runtime: SourceRuntime, owner_account_id: Option<&str>, ) -> RuntimeStoreRecordInput { @@ -4666,7 +3401,7 @@ mod tests { owner_account_id: owner_account_id.map(str::to_owned), owner_pubkey: Some(event.author_str().to_owned()), farm_id: None, - listing_addr: Some(listing_addr.to_owned()), + listing_addr: None, local_work_json: None, event_id: Some(event.id_str().to_owned()), event_kind: Some(i64::from(event.kind_u32())), @@ -4685,1518 +3420,81 @@ mod tests { "sig": event.sig_str(), })), outbox_status: PublishOutboxStatus::Acknowledged, - relay_set_fingerprint: Some("relay-set".to_owned()), - relay_delivery_json: Some(relay_delivery_json), - } - } - - #[test] - fn imports_signed_order_request_into_seller_order_projection() { - let app_store = - AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); - let events = runtime_store_store(); - let farm_key = "AAAAAAAAAAAAAAAAAAAAAA"; - let listing_key = "AAAAAAAAAAAAAAAAAAAAAg"; - let seller_pubkey = test_pubkey("seller-pubkey"); - let seller_pubkey = seller_pubkey.as_str(); - let buyer_pubkey = test_pubkey("buyer-pubkey"); - let buyer_pubkey = buyer_pubkey.as_str(); - let order_id_raw = "relay-order-1"; - let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); - events - .append_record(&signed_market_listing_record( - "order-visible-listing", - seller_pubkey, - farm_key, - listing_key, - "Order Visible Eggs", - "9", - "active", - "pickup", - "North barn pickup", - 4_102_444_800, - 4_102_531_200, - RuntimeStoreRecordStatus::Published, - PublishOutboxStatus::Acknowledged, - )) - .expect("append signed listing"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import signed listing"); - let payload = order_request_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let parts = order_request_event_build(&listing_event_ptr("listing-event-1"), &payload) - .expect("build order request event"); - let event = event_from_parts("order-request-event-1", buyer_pubkey, parts); - events - .append_record(&signed_order_event_record( - "cli:signed_event:order-request:1", - &event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - )) - .expect("append order request"); - - let report = app_store - .import_shared_runtime_store_from_store(&events) - .expect("import signed order request"); - let farm_id = deterministic_farm_id(Some(seller_pubkey), farm_key); - let order_id = projected_order_id(order_id_raw, buyer_pubkey, event.id_str()); - let orders = app_store - .load_orders_list( - farm_id, - &OrdersScreenQueryState { - filter: OrdersFilter::All, - fulfillment_window_id: None, - }, - ) - .expect("load seller orders"); - let detail = app_store - .load_order_detail(farm_id, order_id) - .expect("load order detail") - .expect("order detail"); - let imported = app_store - .load_local_interop_records() - .expect("load imported records"); - let signed_evidence = app_store - .load_local_interop_signed_events_by_kind(KIND_ORDER_REQUEST) - .expect("load signed event evidence"); - let buyer_context_key: String = app_store - .connection() - .query_row( - "SELECT buyer_context_key FROM orders WHERE id = ?1", - [order_id.to_string()], - |row| row.try_get(0), - ) - .expect("load buyer context key"); - - assert_eq!(report.imported_records, 1); - assert!( - imported - .iter() - .any(|record| record.projected_kind == "signed_event" - && record.event_kind == Some(KIND_ORDER_REQUEST) - && record.event_id.as_deref() == Some(event.id_str())) - ); - assert_eq!(signed_evidence, vec![event.clone()]); - assert_eq!(orders.rows.len(), 1); - assert_eq!(orders.rows[0].order_id, order_id); - assert_eq!(orders.rows[0].status, OrderStatus::NeedsAction); - assert_eq!( - orders.rows[0].customer_display_name, - format!("Relay buyer {}", &buyer_pubkey[..12]) - ); - assert_eq!(detail.items.len(), 1); - assert_eq!(detail.items[0].title, "Order Visible Eggs"); - assert_eq!(detail.items[0].quantity_display, "2 each"); - assert_eq!(buyer_context_key, format!("nostr:{buyer_pubkey}")); - } - - #[test] - fn local_interop_order_request_evidence_requires_usable_delivery_state() { - let app_store = - AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); - let events = runtime_store_store(); - let buyer_pubkey = test_pubkey("buyer-pubkey"); - let buyer_pubkey = buyer_pubkey.as_str(); - let seller_pubkey = test_pubkey("seller-pubkey"); - let seller_pubkey = seller_pubkey.as_str(); - let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); - let relay_url = "ws://127.0.0.1:1234"; - let build_event = |event_id: &str, order_id_raw: &str| { - let payload = order_request_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let parts = order_request_event_build(&listing_event_ptr("listing-event-1"), &payload) - .expect("build order request event"); - event_from_parts(event_id, buyer_pubkey, parts) - }; - let acknowledged_event = build_event("order-request-evidence-ack", "usable-ack"); - events - .append_record(&signed_order_event_record( - "cli:signed_event:order-request:evidence-ack", - &acknowledged_event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - )) - .expect("append acknowledged order request evidence"); - - let observed_event = build_event("order-request-evidence-observed", "usable-observed"); - let mut observed_record = signed_order_event_record( - "cli:signed_event:order-request:evidence-observed", - &observed_event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - ); - observed_record.outbox_status = PublishOutboxStatus::None; - observed_record.relay_delivery_json = Some( - RelayDeliveryEvidence::observed([relay_url], [relay_url], [relay_url], Vec::new()) - .expect("observed relay evidence") - .to_json_value() - .expect("observed relay evidence json"), - ); - events - .append_record(&observed_record) - .expect("append observed order request evidence"); - - let pending_event = build_event("order-request-evidence-pending", "pending"); - let mut pending_record = signed_order_event_record( - "cli:signed_event:order-request:evidence-pending", - &pending_event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - ); - pending_record.status = RuntimeStoreRecordStatus::PendingPublish; - pending_record.outbox_status = PublishOutboxStatus::Pending; - pending_record.relay_delivery_json = Some( - RelayDeliveryEvidence::pending([relay_url]) - .expect("pending relay evidence") - .to_json_value() - .expect("pending relay evidence json"), - ); - events - .append_record(&pending_record) - .expect("append pending order request evidence"); - - let failed_event = build_event("order-request-evidence-failed", "failed"); - let mut failed_record = signed_order_event_record( - "cli:signed_event:order-request:evidence-failed", - &failed_event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - ); - failed_record.outbox_status = PublishOutboxStatus::Failed; - failed_record.relay_delivery_json = Some(json!({ - "state": "failed", - "target_relays": [relay_url], - "connected_relays": [relay_url], - "acknowledged_relays": [], - "failed_relays": [{"relay_url": relay_url, "error": "relay rejected event"}] - })); - events - .append_record(&failed_record) - .expect("append failed order request evidence"); - - let local_only_event = build_event("order-request-evidence-local-only", "local-only"); - let mut local_only_record = signed_order_event_record( - "cli:signed_event:order-request:evidence-local-only", - &local_only_event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - ); - local_only_record.outbox_status = PublishOutboxStatus::None; - local_only_record.relay_set_fingerprint = None; - local_only_record.relay_delivery_json = None; - events - .append_record(&local_only_record) - .expect("append local-only order request evidence"); - - let invalid_delivery_event = build_event( - "order-request-evidence-invalid-delivery", - "invalid-delivery", - ); - let mut invalid_delivery_record = signed_order_event_record( - "cli:signed_event:order-request:evidence-malformed-delivery", - &invalid_delivery_event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - ); - invalid_delivery_record.relay_delivery_json = Some(json!({ - "state": "acknowledged" - })); - let invalid_delivery_error = events - .append_record(&invalid_delivery_record) - .expect_err("reject invalid delivery order request evidence"); - assert!( - invalid_delivery_error - .to_string() - .contains("acknowledged relay delivery evidence requires acknowledged_relays") - ); - - let malformed_event = - build_event("order-request-evidence-malformed-event", "malformed-event"); - let mut malformed_record = signed_order_event_record( - "cli:signed_event:order-request:evidence-malformed-event", - &malformed_event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - ); - malformed_record.event_tags_json = Some(json!({"invalid": "tags"})); - events - .append_record(&malformed_record) - .expect("append malformed order request evidence"); - - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import signed evidence records"); - let signed_evidence = app_store - .load_local_interop_signed_events_by_kind(KIND_ORDER_REQUEST) - .expect("load filtered signed event evidence"); - - assert_eq!(signed_evidence, vec![acknowledged_event, observed_event]); - } - - #[test] - fn app_origin_signed_order_request_and_decision_project_to_buyer_orders() { - let app_store = - AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); - let events = runtime_store_store(); - let farm_key = "CCCCCCCCCCCCCCCCCCCCCC"; - let listing_key = "AAAAAAAAAAAAAAAAAAAAAg"; - let seller_pubkey = test_pubkey("seller-pubkey"); - let seller_pubkey = seller_pubkey.as_str(); - let buyer_pubkey = test_pubkey("app-buyer-pubkey"); - let buyer_pubkey = buyer_pubkey.as_str(); - let order_id_raw = "app-relay-order-1"; - let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); - events - .append_record(&signed_market_listing_record( - "buyer-order-listing", - seller_pubkey, - farm_key, - listing_key, - "Buyer Order Eggs", - "9", - "active", - "pickup", - "North barn pickup", - 4_102_444_800, - 4_102_531_200, - RuntimeStoreRecordStatus::Published, - PublishOutboxStatus::Acknowledged, - )) - .expect("append signed listing"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import signed listing"); - let request_payload = order_request_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let request_parts = order_request_event_build( - &listing_event_ptr("buyer-order-listing-event"), - &request_payload, - ) - .expect("build order request event"); - let request_event = - event_from_parts("buyer-order-request-event", buyer_pubkey, request_parts); - events - .append_record(&signed_order_event_record( - "app:signed_event:order-request:buyer", - &request_event, - listing_addr.as_str(), - SourceRuntime::App, - Some("acct_buyer"), - )) - .expect("append app order request"); - - let request_report = app_store - .import_shared_runtime_store_from_store(&events) - .expect("import app order request"); - let buyer_context = BuyerContext::account("acct_buyer"); - let order_id = projected_order_id(order_id_raw, buyer_pubkey, request_event.id_str()); - let farm_id = deterministic_farm_id(Some(seller_pubkey), farm_key); - let buyer_orders = app_store - .load_buyer_orders(&buyer_context) - .expect("load buyer orders after request"); - - assert_eq!(request_report.imported_records, 1); - assert_eq!(buyer_orders.rows.len(), 1); - assert_eq!(buyer_orders.rows[0].order_id, order_id); - assert_eq!(buyer_orders.rows[0].status, BuyerOrderStatus::Placed); - - let decision_payload = accepted_order_decision_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let decision_parts = order_decision_event_build( - &typed_event_id(request_event.id_str()), - &typed_event_id(request_event.id_str()), - &decision_payload, - ) - .expect("build order decision event"); - let decision_event = - event_from_parts("buyer-order-decision-event", seller_pubkey, decision_parts); - events - .append_record(&signed_order_event_record( - "cli:signed_event:order-decision:buyer", - &decision_event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - )) - .expect("append order decision"); - - let decision_report = app_store - .import_shared_runtime_store_from_store(&events) - .expect("import order decision"); - let buyer_orders = app_store - .load_buyer_orders(&buyer_context) - .expect("load buyer orders after decision"); - let buyer_detail = app_store - .load_buyer_order_detail(&buyer_context, order_id) - .expect("load buyer order detail") - .expect("buyer order detail"); - let seller_orders = app_store - .load_orders_list( - farm_id, - &OrdersScreenQueryState { - filter: OrdersFilter::All, - fulfillment_window_id: None, - }, - ) - .expect("load seller orders after decision"); - - assert_eq!(decision_report.imported_records, 1); - assert_eq!(buyer_orders.rows.len(), 1); - assert_eq!(buyer_orders.rows[0].status, BuyerOrderStatus::Placed); - assert_eq!(buyer_detail.status, BuyerOrderStatus::Placed); - assert_eq!(seller_orders.rows[0].status, OrderStatus::NeedsAction); - assert_eq!(buyer_detail.workflow, buyer_orders.rows[0].workflow); - assert_eq!( - seller_orders.rows[0].workflow, - buyer_orders.rows[0].workflow - ); - assert_eq!( - buyer_orders.rows[0].workflow.agreement, - TradeAgreementStatus::AgreedPendingValidation - ); - assert_eq!( - buyer_orders.rows[0].workflow.inventory, - TradeInventoryStatus::Reserved - ); - assert_eq!( - buyer_orders.rows[0].workflow.provenance.primary_source, - TradeWorkflowSource::RuntimeStore - ); - assert_eq!( - buyer_orders.rows[0] - .workflow - .provenance - .last_event_id - .as_deref(), - Some(decision_event.id_str()) - ); - assert_eq!( - buyer_orders.rows[0].workflow.economics.total_minor_units, - Some(1600) - ); - } - - #[test] - fn duplicate_app_order_request_roots_project_to_distinct_buyer_orders() { - let app_store = - AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); - let events = runtime_store_store(); - let farm_key = "CCCCCCCCCCCCCCCCCCCCCC"; - let listing_key = "AAAAAAAAAAAAAAAAAAAAAy"; - let seller_pubkey = test_pubkey("duplicate-root-seller-pubkey"); - let seller_pubkey = seller_pubkey.as_str(); - let buyer_pubkey = test_pubkey("duplicate-root-buyer-pubkey"); - let buyer_pubkey = buyer_pubkey.as_str(); - let order_id_raw = "duplicate-root-order"; - let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); - events - .append_record(&signed_market_listing_record( - "duplicate-root-listing", - seller_pubkey, - farm_key, - listing_key, - "Duplicate Root Eggs", - "9", - "active", - "pickup", - "North barn pickup", - 4_102_444_800, - 4_102_531_200, - RuntimeStoreRecordStatus::Published, - PublishOutboxStatus::Acknowledged, - )) - .expect("append duplicate-root listing"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import duplicate-root listing"); - - let request_payload = order_request_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let first_request_parts = order_request_event_build( - &listing_event_ptr("duplicate-root-listing-event"), - &request_payload, - ) - .expect("build duplicate-root order request"); - let first_request_event = event_from_parts( - "duplicate-root-request-first", - buyer_pubkey, - first_request_parts, - ); - let second_request_parts = order_request_event_build( - &listing_event_ptr("duplicate-root-listing-event"), - &request_payload, - ) - .expect("build second duplicate-root order request"); - let second_request_event = event_from_parts( - "duplicate-root-request-second", - buyer_pubkey, - second_request_parts, - ); - for (record_id, event) in [ - ( - "app:signed_event:duplicate-root:first", - &first_request_event, - ), - ( - "app:signed_event:duplicate-root:second", - &second_request_event, - ), - ] { - events - .append_record(&signed_order_event_record( - record_id, - event, - listing_addr.as_str(), - SourceRuntime::App, - Some("acct_duplicate_root"), - )) - .expect("append duplicate-root request"); - } - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import duplicate-root requests"); - - let first_order_id = - projected_order_id(order_id_raw, buyer_pubkey, first_request_event.id_str()); - let second_order_id = - projected_order_id(order_id_raw, buyer_pubkey, second_request_event.id_str()); - let buyer_context = BuyerContext::account("acct_duplicate_root"); - let buyer_orders = app_store - .load_buyer_orders(&buyer_context) - .expect("load duplicate-root buyer orders"); - let projected_ids = buyer_orders - .rows - .iter() - .map(|row| row.order_id) - .collect::<Vec<_>>(); - - assert_ne!(first_order_id, second_order_id); - assert_eq!(buyer_orders.rows.len(), 2); - assert!(projected_ids.contains(&first_order_id)); - assert!(projected_ids.contains(&second_order_id)); - } - - #[test] - fn app_origin_signed_order_request_and_decline_project_to_buyer_orders() { - let app_store = - AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); - let events = runtime_store_store(); - let farm_key = "CCCCCCCCCCCCCCCCCCCCCC"; - let listing_key = "AAAAAAAAAAAAAAAAAAAAAg"; - let seller_pubkey = test_pubkey("seller-pubkey"); - let seller_pubkey = seller_pubkey.as_str(); - let buyer_pubkey = test_pubkey("app-buyer-pubkey"); - let buyer_pubkey = buyer_pubkey.as_str(); - let order_id_raw = "app-relay-order-declined-1"; - let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); - events - .append_record(&signed_market_listing_record( - "buyer-order-decline-listing", - seller_pubkey, - farm_key, - listing_key, - "Buyer Order Eggs", - "9", - "active", - "pickup", - "North barn pickup", - 4_102_444_800, - 4_102_531_200, - RuntimeStoreRecordStatus::Published, - PublishOutboxStatus::Acknowledged, - )) - .expect("append signed listing"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import signed listing"); - let request_payload = order_request_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let request_parts = order_request_event_build( - &listing_event_ptr("buyer-order-decline-listing-event"), - &request_payload, - ) - .expect("build order request event"); - let request_event = event_from_parts( - "buyer-order-decline-request-event", - buyer_pubkey, - request_parts, - ); - events - .append_record(&signed_order_event_record( - "app:signed_event:order-request:buyer-declined", - &request_event, - listing_addr.as_str(), - SourceRuntime::App, - Some("acct_buyer"), - )) - .expect("append app order request"); - - let request_report = app_store - .import_shared_runtime_store_from_store(&events) - .expect("import app order request"); - let buyer_context = BuyerContext::account("acct_buyer"); - let order_id = projected_order_id(order_id_raw, buyer_pubkey, request_event.id_str()); - let buyer_orders = app_store - .load_buyer_orders(&buyer_context) - .expect("load buyer orders after request"); - - assert_eq!(request_report.imported_records, 1); - assert_eq!(buyer_orders.rows.len(), 1); - assert_eq!(buyer_orders.rows[0].order_id, order_id); - assert_eq!(buyer_orders.rows[0].status, BuyerOrderStatus::Placed); - - let decision_payload = declined_order_decision_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let decision_parts = order_decision_event_build( - &typed_event_id(request_event.id_str()), - &typed_event_id(request_event.id_str()), - &decision_payload, - ) - .expect("build declined order decision event"); - let decision_event = event_from_parts( - "buyer-order-decline-decision-event", - seller_pubkey, - decision_parts, - ); - events - .append_record(&signed_order_event_record( - "cli:signed_event:order-decision:buyer-declined", - &decision_event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - )) - .expect("append declined order decision"); - - let decision_report = app_store - .import_shared_runtime_store_from_store(&events) - .expect("import declined order decision"); - let buyer_orders = app_store - .load_buyer_orders(&buyer_context) - .expect("load buyer orders after declined decision"); - let buyer_detail = app_store - .load_buyer_order_detail(&buyer_context, order_id) - .expect("load buyer order detail") - .expect("buyer order detail"); - let seller_orders = app_store - .load_orders_list( - deterministic_farm_id(Some(seller_pubkey), farm_key), - &OrdersScreenQueryState { - filter: OrdersFilter::All, - fulfillment_window_id: None, - }, - ) - .expect("load seller orders after declined decision"); - - assert_eq!(decision_report.imported_records, 1); - assert_eq!(buyer_orders.rows.len(), 1); - assert_eq!(buyer_orders.rows[0].status, BuyerOrderStatus::Declined); - assert_eq!(buyer_detail.status, BuyerOrderStatus::Declined); - assert_eq!(seller_orders.rows[0].status, OrderStatus::Declined); - assert_eq!(seller_orders.summary.needs_action_orders, 0); - assert_eq!(seller_orders.summary.scheduled_orders, 0); - assert_eq!(seller_orders.summary.packed_orders, 0); - assert!(seller_orders.rows[0].primary_action.is_none()); - } - - #[test] - fn active_order_decision_projects_agreement_state_through_cli_reducer() { - let app_store = - AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); - let events = runtime_store_store(); - let farm_key = "DDDDDDDDDDDDDDDDDDDDDD"; - let listing_key = "AAAAAAAAAAAAAAAAAAAAAw"; - let seller_pubkey = test_pubkey("seller-pubkey"); - let seller_pubkey = seller_pubkey.as_str(); - let buyer_pubkey = test_pubkey("app-buyer-pubkey"); - let buyer_pubkey = buyer_pubkey.as_str(); - let order_id_raw = "active-lifecycle-order-1"; - let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); - events - .append_record(&signed_market_listing_record( - "active-lifecycle-listing", - seller_pubkey, - farm_key, - listing_key, - "Lifecycle Eggs", - "9", - "active", - "pickup", - "North barn pickup", - 4_102_444_800, - 4_102_531_200, - RuntimeStoreRecordStatus::Published, - PublishOutboxStatus::Acknowledged, - )) - .expect("append signed listing"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import signed listing"); - - let request_payload = order_request_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let request_parts = order_request_event_build( - &listing_event_ptr("active-lifecycle-listing-event"), - &request_payload, - ) - .expect("build lifecycle order request"); - let request_event = event_from_parts( - "active-lifecycle-request-event", - buyer_pubkey, - request_parts, - ); - events - .append_record(&signed_order_event_record( - "app:signed_event:active-lifecycle:request", - &request_event, - listing_addr.as_str(), - SourceRuntime::App, - Some("acct_lifecycle"), - )) - .expect("append lifecycle order request"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import lifecycle order request"); - - let seller_farm_id = deterministic_farm_id(Some(seller_pubkey), farm_key); - let decision_payload = accepted_order_decision_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let decision_parts = order_decision_event_build( - &typed_event_id(request_event.id_str()), - &typed_event_id(request_event.id_str()), - &decision_payload, - ) - .expect("build lifecycle order decision"); - let decision_event = event_from_parts( - "active-lifecycle-decision-event", - seller_pubkey, - decision_parts, - ); - events - .append_record(&signed_order_event_record( - "cli:signed_event:active-lifecycle:decision", - &decision_event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - )) - .expect("append lifecycle order decision"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import lifecycle order decision"); - let seller_orders = app_store - .load_orders_list( - seller_farm_id, - &OrdersScreenQueryState { - filter: OrdersFilter::All, - fulfillment_window_id: None, - }, - ) - .expect("load lifecycle seller orders after decision"); - assert_eq!(seller_orders.rows[0].status, OrderStatus::NeedsAction); - assert_eq!( - seller_orders.rows[0].workflow.agreement, - TradeAgreementStatus::AgreedPendingValidation - ); - assert_eq!( - seller_orders.rows[0].workflow.inventory, - TradeInventoryStatus::Reserved - ); - assert_eq!(seller_orders.rows[0].primary_action, None); - } - - #[test] - fn valid_validation_receipt_keeps_buyer_and_seller_order_pending_validation() { - let fixture = - validation_receipt_order_fixture("validation-receipt-valid-pending-validation"); - let valid_event = validation_receipt_event( - hex_event_id(29).as_str(), - fixture.seller_pubkey.as_str(), - fixture.order_id_raw.as_str(), - fixture.listing_event_id.as_str(), - fixture.request_event_id.as_str(), - fixture.decision_event_id.as_str(), - RadrootsValidationReceiptResult::Valid, - 1_777_665_603, - ); - fixture - .events - .append_record(&signed_order_event_record( - "cli:signed_event:validation-receipt:valid-pending-validation", - &valid_event, - fixture.listing_addr.as_str(), - SourceRuntime::Cli, - None, - )) - .expect("append valid validation receipt"); - fixture - .app_store - .import_shared_runtime_store_from_store(&fixture.events) - .expect("import valid validation receipt"); - - let buyer_detail = fixture - .app_store - .load_buyer_order_detail(&fixture.buyer_context, fixture.order_id) - .expect("load buyer pending-validation validation receipt detail") - .expect("buyer pending-validation validation receipt detail"); - let seller_detail = fixture - .app_store - .load_order_detail(fixture.seller_farm_id, fixture.order_id) - .expect("load seller pending-validation validation receipt detail") - .expect("seller pending-validation validation receipt detail"); - - assert_eq!(buyer_detail.status, BuyerOrderStatus::Placed); - assert_eq!(seller_detail.status, OrderStatus::NeedsAction); - assert_eq!( - buyer_detail.workflow.agreement, - TradeAgreementStatus::AgreedPendingValidation - ); - assert_eq!( - seller_detail.workflow.agreement, - TradeAgreementStatus::AgreedPendingValidation - ); - assert_eq!( - buyer_detail.workflow.inventory, - TradeInventoryStatus::Reserved - ); - assert_eq!( - buyer_detail.workflow.provenance.last_event_id.as_deref(), - Some(fixture.decision_event_id.as_str()) - ); - assert_eq!(buyer_detail.validation_receipts.len(), 1); - assert_eq!( - buyer_detail.validation_receipts[0].result, - TradeValidationReceiptResult::Valid - ); - } - - #[test] - fn validation_receipts_drive_buyer_and_seller_order_details() { - let fixture = validation_receipt_order_fixture("validation-receipt-driven"); - let valid_event = validation_receipt_event( - hex_event_id(31).as_str(), - fixture.seller_pubkey.as_str(), - fixture.order_id_raw.as_str(), - fixture.listing_event_id.as_str(), - fixture.request_event_id.as_str(), - fixture.decision_event_id.as_str(), - RadrootsValidationReceiptResult::Valid, - 1_777_665_603, - ); - let invalid_event = validation_receipt_event( - hex_event_id(32).as_str(), - fixture.seller_pubkey.as_str(), - fixture.order_id_raw.as_str(), - fixture.listing_event_id.as_str(), - fixture.request_event_id.as_str(), - fixture.decision_event_id.as_str(), - RadrootsValidationReceiptResult::Invalid, - 1_777_665_604, - ); - let duplicate_valid_event = valid_event.clone(); - fixture - .events - .append_record(&signed_order_event_record( - "cli:signed_event:validation-receipt:valid", - &valid_event, - fixture.listing_addr.as_str(), - SourceRuntime::Cli, - None, - )) - .expect("append valid validation receipt"); - fixture - .events - .append_record(&signed_order_event_record( - "cli:signed_event:validation-receipt:invalid", - &invalid_event, - fixture.listing_addr.as_str(), - SourceRuntime::Cli, - None, - )) - .expect("append invalid validation receipt"); - fixture - .events - .append_record(&signed_order_event_record( - "cli:signed_event:validation-receipt:valid-duplicate", - &duplicate_valid_event, - fixture.listing_addr.as_str(), - SourceRuntime::Cli, - None, - )) - .expect("append duplicate validation receipt"); - fixture - .app_store - .import_shared_runtime_store_from_store(&fixture.events) - .expect("import validation receipts"); - - let buyer_detail = fixture - .app_store - .load_buyer_order_detail(&fixture.buyer_context, fixture.order_id) - .expect("load buyer validation receipt detail") - .expect("buyer validation receipt detail"); - let seller_detail = fixture - .app_store - .load_order_detail(fixture.seller_farm_id, fixture.order_id) - .expect("load seller validation receipt detail") - .expect("seller validation receipt detail"); - let imports = fixture - .app_store - .load_local_interop_records() - .expect("load validation receipt imports"); - - assert_eq!(buyer_detail.status, BuyerOrderStatus::NeedsReview); - assert_eq!(seller_detail.status, OrderStatus::NeedsReview); - assert_eq!( - buyer_detail.workflow.agreement, - TradeAgreementStatus::Invalid - ); - assert_eq!( - seller_detail.workflow.agreement, - TradeAgreementStatus::Invalid - ); - assert_eq!( - buyer_detail.workflow.inventory, - TradeInventoryStatus::NeedsReview - ); - assert_eq!( - buyer_detail.workflow.provenance.last_event_id.as_deref(), - Some(fixture.decision_event_id.as_str()) - ); - assert_eq!(seller_detail.primary_action, None); - assert_eq!(buyer_detail.validation_receipts.len(), 2); - assert_eq!( - buyer_detail.validation_receipts, - seller_detail.validation_receipts - ); - assert_eq!( - buyer_detail.validation_receipts[0].event_id, - invalid_event.id_str() - ); - assert_eq!( - buyer_detail.validation_receipts[0].result, - TradeValidationReceiptResult::NeedsReview - ); - assert_eq!( - buyer_detail.validation_receipts[0].receipt_type, - TradeValidationReceiptType::TradeTransition - ); - assert_eq!( - buyer_detail.validation_receipts[0].proof_system, - TradeValidationReceiptProofSystem::None - ); - assert_eq!( - buyer_detail.validation_receipts[0].target_event_id, - fixture.decision_event_id - ); - assert_eq!( - buyer_detail.validation_receipts[0].event_set_root, - hash32(42) - ); - assert_eq!( - buyer_detail.validation_receipts[0].reducer_output_root, - hash32(43) - ); - assert_eq!( - buyer_detail.validation_receipts[0].public_values_hash, - hash32(45) - ); - assert_eq!( - buyer_detail.validation_receipts[0].recorded_at, - 1_777_665_604 - ); - assert_eq!( - buyer_detail.validation_receipts[1].event_id, - valid_event.id_str() - ); - assert_eq!( - buyer_detail.validation_receipts[1].result, - TradeValidationReceiptResult::Valid - ); - assert!( - imports - .iter() - .any(|record| record.projected_kind == "validation_receipt" - && record.event_kind == Some(KIND_VALIDATION_RECEIPT) - && record.event_id.as_deref() == Some(invalid_event.id_str())) - ); + relay_set_fingerprint: Some("relay-set".to_owned()), + relay_delivery_json: Some(relay_delivery_json), + } } #[test] - fn validation_receipt_import_before_order_request_attaches_when_request_arrives() { + fn validation_receipt_import_is_passive_signed_receipt_evidence() { let app_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); let events = runtime_store_store(); - let farm_key = "DDDDDDDDDDDDDDDDDDDDDD"; - let listing_key = "AAAAAAAAAAAAAAAAAAAAAw"; - let seller_pubkey = test_pubkey("validation-out-of-order-seller"); - let seller_pubkey = seller_pubkey.as_str(); - let buyer_pubkey = test_pubkey("validation-out-of-order-buyer"); - let buyer_pubkey = buyer_pubkey.as_str(); - let order_id_raw = "validation-out-of-order"; - let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); - let listing_event_id = hex_event_id(51); - let request_event_id = hex_event_id(52); - let target_event_id = hex_event_id(53); - events - .append_record(&signed_market_listing_record( - "validation-out-of-order-listing", - seller_pubkey, - farm_key, - listing_key, - "Validation Eggs", - "9", - "active", - "pickup", - "North barn pickup", - 4_102_444_800, - 4_102_531_200, - RuntimeStoreRecordStatus::Published, - PublishOutboxStatus::Acknowledged, - )) - .expect("append out-of-order listing"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import out-of-order listing"); - - let receipt_event = validation_receipt_event( - hex_event_id(54).as_str(), - seller_pubkey, - order_id_raw, - listing_event_id.as_str(), - request_event_id.as_str(), - target_event_id.as_str(), + let event = validation_receipt_event( + hex_event_id(29).as_str(), + test_pubkey("receipt-seller").as_str(), + "passive-receipt-order", + hex_event_id(30).as_str(), + hex_event_id(31).as_str(), + hex_event_id(32).as_str(), RadrootsValidationReceiptResult::Valid, 1_777_665_603, ); events - .append_record(&signed_order_event_record( - "cli:signed_event:validation-receipt:before-request", - &receipt_event, - listing_addr.as_str(), + .append_record(&signed_receipt_event_record( + "cli:signed_event:validation-receipt:passive", + &event, SourceRuntime::Cli, None, )) - .expect("append receipt before request"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import receipt before request"); + .expect("append validation receipt"); - let pending_count: i64 = app_store + let report = app_store + .import_shared_runtime_store_from_store(&events) + .expect("import validation receipt"); + let imports = app_store + .load_local_interop_records() + .expect("load validation receipt imports"); + let receipt_row = app_store .connection() .query_row( - "SELECT count(*) FROM order_validation_receipts WHERE order_id IS NULL", - crate::empty_params(), - |row| row.try_get(0), - ) - .expect("count pending validation receipts"); - assert_eq!(pending_count, 1); - - let request_payload = order_request_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let request_parts = order_request_event_build( - &listing_event_ptr(listing_event_id.as_str()), - &request_payload, - ) - .expect("build request after validation receipt"); - let request_event = event_from_parts_at( - request_event_id.as_str(), - buyer_pubkey, - request_parts, - 1_777_665_604, - ); - events - .append_record(&signed_order_event_record( - "app:signed_event:validation-receipt:request-after", - &request_event, - listing_addr.as_str(), - SourceRuntime::App, - Some("acct_validation_out_of_order"), - )) - .expect("append request after receipt"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import request after receipt"); - - let order_id = projected_order_id(order_id_raw, buyer_pubkey, request_event.id_str()); - let buyer_context = BuyerContext::account("acct_validation_out_of_order"); - let buyer_detail = app_store - .load_buyer_order_detail(&buyer_context, order_id) - .expect("load attached buyer validation receipt detail") - .expect("attached buyer validation receipt detail"); - let seller_detail = app_store - .load_order_detail( - deterministic_farm_id(Some(seller_pubkey), farm_key), - order_id, - ) - .expect("load attached seller validation receipt detail") - .expect("attached seller validation receipt detail"); - - assert_eq!(buyer_detail.validation_receipts.len(), 1); - assert_eq!( - buyer_detail.validation_receipts, - seller_detail.validation_receipts - ); - assert_eq!( - buyer_detail.validation_receipts[0].event_id, - receipt_event.id_str() - ); - assert_eq!(buyer_detail.status, BuyerOrderStatus::Placed); - assert_eq!(seller_detail.status, OrderStatus::NeedsAction); - } - - #[test] - fn validation_receipt_invalid_candidates_do_not_surface_as_order_evidence() { - let fixture = validation_receipt_order_fixture("validation-receipt-invalid-candidates"); - let mismatched_tag_event = validation_receipt_event( - hex_event_id(61).as_str(), - fixture.seller_pubkey.as_str(), - fixture.order_id_raw.as_str(), - fixture.listing_event_id.as_str(), - fixture.request_event_id.as_str(), - fixture.decision_event_id.as_str(), - RadrootsValidationReceiptResult::Valid, - 1_777_665_603, - ); - let mut mismatched_tags = mismatched_tag_event.tags_as_vec(); - if let Some(tag) = mismatched_tags - .iter_mut() - .find(|tag| tag.first().map(String::as_str) == Some("event_set_root")) - { - tag[1] = hash32(99); - } - let mismatched_tag_event = event_with_tags(&mismatched_tag_event, mismatched_tags); - let wrong_order_event = validation_receipt_event( - hex_event_id(62).as_str(), - fixture.seller_pubkey.as_str(), - "wrong-order-id", - fixture.listing_event_id.as_str(), - fixture.request_event_id.as_str(), - fixture.decision_event_id.as_str(), - RadrootsValidationReceiptResult::Valid, - 1_777_665_604, - ); - let buyer_kind_candidate = validation_receipt_event( - hex_event_id(63).as_str(), - fixture.buyer_pubkey.as_str(), - fixture.order_id_raw.as_str(), - fixture.listing_event_id.as_str(), - fixture.request_event_id.as_str(), - fixture.decision_event_id.as_str(), - RadrootsValidationReceiptResult::Valid, - 1_777_665_605, - ); - let buyer_kind_candidate = - event_with_kind(&buyer_kind_candidate, KIND_ORDER_REQUEST as u32); - - for (record_id, event) in [ - ( - "cli:signed_event:validation-receipt:mismatched-tag", - &mismatched_tag_event, - ), - ( - "cli:signed_event:validation-receipt:wrong-order", - &wrong_order_event, - ), - ( - "cli:signed_event:validation-receipt:buyer-kind", - &buyer_kind_candidate, - ), - ] { - fixture - .events - .append_record(&signed_order_event_record( - record_id, - event, - fixture.listing_addr.as_str(), - SourceRuntime::Cli, - None, - )) - .expect("append invalid validation receipt candidate"); - } - fixture - .app_store - .import_shared_runtime_store_from_store(&fixture.events) - .expect("import invalid validation receipt candidates"); - - let buyer_detail = fixture - .app_store - .load_buyer_order_detail(&fixture.buyer_context, fixture.order_id) - .expect("load buyer detail after invalid validation receipt candidates") - .expect("buyer detail after invalid validation receipt candidates"); - let seller_detail = fixture - .app_store - .load_order_detail(fixture.seller_farm_id, fixture.order_id) - .expect("load seller detail after invalid validation receipt candidates") - .expect("seller detail after invalid validation receipt candidates"); - - assert!(buyer_detail.validation_receipts.is_empty()); - assert!(seller_detail.validation_receipts.is_empty()); - assert_eq!(buyer_detail.status, BuyerOrderStatus::Placed); - assert_eq!(seller_detail.status, OrderStatus::NeedsAction); - assert_eq!( - buyer_detail.workflow.agreement, - TradeAgreementStatus::AgreedPendingValidation - ); - assert_eq!(seller_detail.primary_action, None); - } - - #[test] - fn active_order_pre_agreement_cancellation_projects_through_cli_reducer_state() { - let app_store = - AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); - let events = runtime_store_store(); - let farm_key = "EEEEEEEEEEEEEEEEEEEEEE"; - let listing_key = "AAAAAAAAAAAAAAAAAAAAAx"; - let seller_pubkey = test_pubkey("seller-pubkey"); - let seller_pubkey = seller_pubkey.as_str(); - let buyer_pubkey = test_pubkey("app-buyer-pubkey"); - let buyer_pubkey = buyer_pubkey.as_str(); - let order_id_raw = "active-cancel-order-1"; - let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); - events - .append_record(&signed_market_listing_record( - "active-cancel-listing", - seller_pubkey, - farm_key, - listing_key, - "Cancellation Eggs", - "9", - "active", - "pickup", - "North barn pickup", - 4_102_444_800, - 4_102_531_200, - RuntimeStoreRecordStatus::Published, - PublishOutboxStatus::Acknowledged, - )) - .expect("append cancellation listing"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import cancellation listing"); - - let request_payload = order_request_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let request_parts = order_request_event_build( - &listing_event_ptr("active-cancel-listing-event"), - &request_payload, - ) - .expect("build cancellation order request"); - let request_event = - event_from_parts("active-cancel-request-event", buyer_pubkey, request_parts); - events - .append_record(&signed_order_event_record( - "app:signed_event:active-cancel:request", - &request_event, - listing_addr.as_str(), - SourceRuntime::App, - Some("acct_cancel"), - )) - .expect("append cancellation order request"); - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import cancellation order request"); - - let cancel_payload = order_cancel_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let cancel_parts = order_cancellation_event_build( - &typed_event_id(request_event.id_str()), - &typed_event_id(request_event.id_str()), - &cancel_payload, - ) - .expect("build cancellation"); - let cancel_event = event_from_parts("active-cancel-event", buyer_pubkey, cancel_parts); - events - .append_record(&signed_order_event_record( - "app:signed_event:active-cancel:cancel", - &cancel_event, - listing_addr.as_str(), - SourceRuntime::App, - Some("acct_cancel"), - )) - .expect("append cancellation"); - let cancel_report = app_store - .import_shared_runtime_store_from_store(&events) - .expect("import cancellation"); - - let seller_farm_id = deterministic_farm_id(Some(seller_pubkey), farm_key); - let order_id = projected_order_id(order_id_raw, buyer_pubkey, request_event.id_str()); - let buyer_context = BuyerContext::account("acct_cancel"); - let buyer_detail = app_store - .load_buyer_order_detail(&buyer_context, order_id) - .expect("load cancellation buyer detail") - .expect("cancellation buyer detail"); - let seller_orders = app_store - .load_orders_list( - seller_farm_id, - &OrdersScreenQueryState { - filter: OrdersFilter::All, - fulfillment_window_id: None, + "SELECT order_id, raw_order_id, result, target_event_id + FROM order_validation_receipts + WHERE event_id = ?1", + crate::app_sqlite_params![event.id_str()], + |row| { + Ok(( + row.try_get::<Option<String>, _>(0)?, + row.try_get::<String, _>(1)?, + row.try_get::<String, _>(2)?, + row.try_get::<String, _>(3)?, + )) }, ) - .expect("load cancellation seller orders"); - - assert_eq!(cancel_report.imported_records, 1); - assert_eq!(buyer_detail.status, BuyerOrderStatus::Declined); - assert_eq!( - buyer_detail.workflow.agreement, - TradeAgreementStatus::Cancelled - ); - assert_eq!(seller_orders.rows[0].status, OrderStatus::Declined); - assert_eq!( - seller_orders.rows[0].workflow.agreement, - TradeAgreementStatus::Cancelled - ); - assert_eq!(seller_orders.rows[0].primary_action, None); - } - - #[test] - fn conflicting_order_decisions_project_to_needs_action_deterministically() { - let run_case = |accepted_first: bool| { - let app_store = - AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); - let events = runtime_store_store(); - let farm_key = "FFFFFFFFFFFFFFFFFFFFFF"; - let listing_key = "AAAAAAAAAAAAAAAAAAAAAw"; - let seller_pubkey = test_pubkey("seller-pubkey"); - let seller_pubkey = seller_pubkey.as_str(); - let buyer_pubkey = test_pubkey("app-buyer-pubkey"); - let buyer_pubkey = buyer_pubkey.as_str(); - let order_id_raw = if accepted_first { - "active-conflict-order-accepted-first" - } else { - "active-conflict-order-declined-first" - }; - let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); - events - .append_record(&signed_market_listing_record( - "active-conflict-listing", - seller_pubkey, - farm_key, - listing_key, - "Conflict Eggs", - "9", - "active", - "pickup", - "North barn pickup", - 4_102_444_800, - 4_102_531_200, - RuntimeStoreRecordStatus::Published, - PublishOutboxStatus::Acknowledged, - )) - .expect("append conflict listing"); - let request_payload = order_request_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let request_parts = order_request_event_build( - &listing_event_ptr("active-conflict-listing-event"), - &request_payload, - ) - .expect("build conflict request"); - let request_event = - event_from_parts("active-conflict-request-event", buyer_pubkey, request_parts); - events - .append_record(&signed_order_event_record( - "app:signed_event:active-conflict:request", - &request_event, - listing_addr.as_str(), - SourceRuntime::App, - Some("acct_conflict"), - )) - .expect("append conflict request"); - let accepted_payload = accepted_order_decision_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let accepted_parts = order_decision_event_build( - &typed_event_id(request_event.id_str()), - &typed_event_id(request_event.id_str()), - &accepted_payload, - ) - .expect("build accepted conflict decision"); - let accepted_event = event_from_parts( - "active-conflict-accepted-event", - seller_pubkey, - accepted_parts, - ); - let declined_payload = declined_order_decision_payload( - order_id_raw, - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let declined_parts = order_decision_event_build( - &typed_event_id(request_event.id_str()), - &typed_event_id(request_event.id_str()), - &declined_payload, - ) - .expect("build declined conflict decision"); - let declined_event = event_from_parts( - "active-conflict-declined-event", - seller_pubkey, - declined_parts, - ); - let ordered_events = if accepted_first { - [accepted_event, declined_event] - } else { - [declined_event, accepted_event] - }; - for (index, event) in ordered_events.iter().enumerate() { - events - .append_record(&signed_order_event_record( - &format!("cli:signed_event:active-conflict:decision:{index}"), - event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - )) - .expect("append conflict decision"); - } - - app_store - .import_shared_runtime_store_from_store(&events) - .expect("import conflicting decisions"); - let order_id = projected_order_id(order_id_raw, buyer_pubkey, request_event.id_str()); - let detail = app_store - .load_order_detail( - deterministic_farm_id(Some(seller_pubkey), farm_key), - order_id, - ) - .expect("load conflict order detail") - .expect("conflict order detail"); - detail.status - }; - - assert_eq!(run_case(true), OrderStatus::NeedsAction); - assert_eq!(run_case(false), OrderStatus::NeedsAction); - } - - #[test] - fn malformed_order_event_remains_signed_event_evidence_without_projection() { - let app_store = - AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); - let events = runtime_store_store(); - events - .append_record(&RuntimeStoreRecordInput { - record_id: "cli:signed_event:order-request:malformed".to_owned(), - family: RuntimeStoreRecordFamily::SignedEvent, - status: RuntimeStoreRecordStatus::Published, - source_runtime: SourceRuntime::Cli, - created_at_ms: 1100, - inserted_at_ms: 1101, - owner_account_id: None, - owner_pubkey: Some("buyer-pubkey".to_owned()), - farm_id: None, - listing_addr: Some("30402:seller-pubkey:listing-key".to_owned()), - local_work_json: None, - event_id: Some("malformed-order-event".to_owned()), - event_kind: Some(KIND_ORDER_REQUEST), - event_pubkey: Some("buyer-pubkey".to_owned()), - event_created_at: Some(1100), - event_tags_json: Some(json!([["d", "bad-order"]])), - event_content: Some("not-json".to_owned()), - event_sig: Some("signature".to_owned()), - raw_event_json: Some(json!({ - "id": "malformed-order-event", - "kind": KIND_ORDER_REQUEST, - "pubkey": "buyer-pubkey", - "content": "not-json" - })), - outbox_status: PublishOutboxStatus::Acknowledged, - relay_set_fingerprint: Some("relay-set".to_owned()), - relay_delivery_json: Some(json!({ - "state": "acknowledged", - "acknowledged_relays": ["ws://127.0.0.1:1234/"] - })), - }) - .expect("append malformed order event"); - - let report = app_store - .import_shared_runtime_store_from_store(&events) - .expect("import malformed order event"); - let imported = app_store - .load_local_interop_records() - .expect("load imported records"); + .expect("load passive validation receipt row"); let order_count: i64 = app_store .connection() .query_row( - "SELECT COUNT(*) FROM orders", + "SELECT count(*) FROM orders", crate::empty_params(), |row| row.try_get(0), ) .expect("load order count"); assert_eq!(report.imported_records, 1); - assert_eq!(report.skipped_records, 0); - assert_eq!(imported.len(), 1); - assert_eq!(imported[0].projected_kind, "signed_event"); + assert_eq!(receipt_row.0, None); + assert_eq!(receipt_row.1, "passive-receipt-order"); assert_eq!( - imported[0].event_id.as_deref(), - Some("malformed-order-event") + receipt_row.2, + TradeValidationReceiptResult::Valid.storage_key() ); + assert_eq!(receipt_row.3, hex_event_id(32)); assert_eq!(order_count, 0); + assert!(imports.iter().any(|record| { + record.projected_kind == "validation_receipt" + && record.event_kind == Some(KIND_VALIDATION_RECEIPT) + && record.event_id.as_deref() == Some(event.id_str()) + })); } #[test] @@ -7980,173 +5278,6 @@ mod tests { } #[test] - fn app_order_request_receipt_replaces_prior_relay_duplicate() { - let app_store = - AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); - let events = runtime_store_store(); - let seller_pubkey = test_pubkey("seller-pubkey"); - let seller_pubkey = seller_pubkey.as_str(); - let buyer_pubkey = test_pubkey("buyer-pubkey"); - let buyer_pubkey = buyer_pubkey.as_str(); - let listing_addr = format!("30402:{seller_pubkey}:app-order-listing"); - let payload = order_request_payload( - "app-order-receipt-replaces-relay", - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let parts = order_request_event_build(&listing_event_ptr("listing-event"), &payload) - .expect("build order request event"); - let event = event_from_parts("app-order-request-event", buyer_pubkey, parts); - let mut relay_record = signed_order_event_record( - "app:relay_event:order-request:duplicate", - &event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - ); - relay_record.outbox_status = PublishOutboxStatus::None; - relay_record.relay_delivery_json = Some(json!({ - "state": "observed", - "observed_relays": ["ws://127.0.0.1:1234/"] - })); - let relay_record = events - .append_record(&relay_record) - .expect("append relay order request"); - let app_record = events - .append_record(&signed_order_event_record( - "app:signed_event:order-request:duplicate", - &event, - listing_addr.as_str(), - SourceRuntime::App, - Some("acct_buyer"), - )) - .expect("append app order request receipt"); - - let report = app_store - .import_local_event_records(&[relay_record, app_record]) - .expect("import duplicate order request records"); - let imported = app_store - .load_local_interop_records() - .expect("load imported records"); - let stored = imported - .iter() - .find(|record| record.event_id.as_deref() == Some(event.id_str())) - .expect("app order request evidence"); - - assert_eq!(report.imported_records, 2); - assert_eq!(report.skipped_records, 0); - assert_eq!( - imported - .iter() - .filter(|record| record.event_id.as_deref() == Some(event.id_str())) - .count(), - 1 - ); - assert_eq!(stored.record_id, "app:signed_event:order-request:duplicate"); - assert_eq!(stored.source_runtime, SourceRuntime::App.as_str()); - assert_eq!(stored.owner_account_id.as_deref(), Some("acct_buyer")); - assert_eq!( - stored.outbox_status, - PublishOutboxStatus::Acknowledged.as_str() - ); - assert_eq!(stored.listing_addr.as_deref(), Some(listing_addr.as_str())); - } - - #[test] - fn relay_order_decision_duplicate_does_not_downgrade_app_receipt() { - let app_store = - AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); - let events = runtime_store_store(); - let seller_pubkey = test_pubkey("seller-pubkey"); - let seller_pubkey = seller_pubkey.as_str(); - let buyer_pubkey = test_pubkey("buyer-pubkey"); - let buyer_pubkey = buyer_pubkey.as_str(); - let listing_addr = format!("30402:{seller_pubkey}:app-decision-listing"); - let request_payload = order_request_payload( - "app-decision-receipt", - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let request_parts = - order_request_event_build(&listing_event_ptr("listing-event"), &request_payload) - .expect("build order request event"); - let request_event = - event_from_parts("app-decision-request-event", buyer_pubkey, request_parts); - let decision_payload = accepted_order_decision_payload( - "app-decision-receipt", - listing_addr.as_str(), - buyer_pubkey, - seller_pubkey, - ); - let decision_parts = order_decision_event_build( - &typed_event_id(request_event.id_str()), - &typed_event_id(request_event.id_str()), - &decision_payload, - ) - .expect("build order decision event"); - let decision_event = - event_from_parts("app-order-decision-event", seller_pubkey, decision_parts); - let app_record = events - .append_record(&signed_order_event_record( - "app:signed_event:order-decision:duplicate", - &decision_event, - listing_addr.as_str(), - SourceRuntime::App, - Some("acct_seller"), - )) - .expect("append app order decision receipt"); - let mut relay_record = signed_order_event_record( - "app:relay_event:order-decision:duplicate", - &decision_event, - listing_addr.as_str(), - SourceRuntime::Cli, - None, - ); - relay_record.outbox_status = PublishOutboxStatus::None; - relay_record.relay_delivery_json = Some(json!({ - "state": "observed", - "observed_relays": ["ws://127.0.0.1:1234/"] - })); - let relay_record = events - .append_record(&relay_record) - .expect("append relay order decision"); - - let report = app_store - .import_local_event_records(&[app_record, relay_record]) - .expect("import duplicate order decision records"); - let imported = app_store - .load_local_interop_records() - .expect("load imported records"); - let stored = imported - .iter() - .find(|record| record.event_id.as_deref() == Some(decision_event.id_str())) - .expect("app order decision evidence"); - - assert_eq!(report.imported_records, 1); - assert_eq!(report.skipped_records, 1); - assert_eq!( - imported - .iter() - .filter(|record| record.event_id.as_deref() == Some(decision_event.id_str())) - .count(), - 1 - ); - assert_eq!( - stored.record_id, - "app:signed_event:order-decision:duplicate" - ); - assert_eq!(stored.source_runtime, SourceRuntime::App.as_str()); - assert_eq!(stored.owner_account_id.as_deref(), Some("acct_seller")); - assert_eq!( - stored.outbox_status, - PublishOutboxStatus::Acknowledged.as_str() - ); - assert_eq!(stored.listing_addr.as_deref(), Some(listing_addr.as_str())); - } - - #[test] fn local_work_farm_import_preserves_duplicate_relay_signed_ready_farm() { let app_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs @@ -35,7 +35,6 @@ use sqlx::Row; pub use error::AppSqliteError; pub use interop::{ AppLocalInteropImportReport, AppLocalInteropRepository, StoredLocalInteropRecord, - projected_order_id_from_trade_request, }; pub use migrations::latest_schema_version; pub use repo::{ @@ -926,10 +925,14 @@ mod tests { )); assert!(column_exists(connection, "products", "category")); assert!(column_exists(connection, "products", "listing_bin_id")); - assert!(column_exists(connection, "buyer_carts", "buyer_email")); - assert!(column_exists(connection, "buyer_carts", "buyer_phone")); - assert!(column_exists(connection, "buyer_carts", "buyer_order_note")); - assert!(column_exists( + assert!(!column_exists(connection, "buyer_carts", "buyer_email")); + assert!(!column_exists(connection, "buyer_carts", "buyer_phone")); + assert!(!column_exists( + connection, + "buyer_carts", + "buyer_order_note" + )); + assert!(!column_exists( connection, "buyer_carts", "buyer_order_note_public_confirmed" @@ -961,10 +964,10 @@ mod tests { "listing_relays_json" )); assert!(column_exists(connection, "orders", "buyer_context_key")); - assert!(column_exists(connection, "orders", "buyer_email")); - assert!(column_exists(connection, "orders", "buyer_phone")); - assert!(column_exists(connection, "orders", "buyer_order_note")); - assert!(column_exists( + assert!(!column_exists(connection, "orders", "buyer_email")); + assert!(!column_exists(connection, "orders", "buyer_phone")); + assert!(!column_exists(connection, "orders", "buyer_order_note")); + assert!(!column_exists( connection, "orders", "buyer_order_note_public_confirmed" @@ -1199,18 +1202,18 @@ mod tests { workflow_agreement, workflow_inventory ) VALUES ( - 'order_agreed_pending_validation', + 'order_contested', 'farm_schema', - 'agreed pending validation', + 'contested', 'Buyer', 'needs_action', '2026-01-01T00:00:00Z', - 'agreed_pending_validation', + 'contested', 'reserved' )", crate::empty_params(), ) - .expect("pending validation workflow projection should insert"); + .expect("contested workflow projection should insert"); let invalid_result = connection.execute( "INSERT INTO orders ( @@ -1271,10 +1274,7 @@ mod tests { customer_display_name, status, updated_at, - buyer_context_key, - buyer_email, - buyer_phone, - buyer_order_note + buyer_context_key ) VALUES ( 'order_status', 'farm_status', @@ -1283,10 +1283,7 @@ mod tests { 'Schema Buyer', 'needs_action', '2026-01-01T00:00:00Z', - 'account:buyer', - '', - '', - '' + 'account:buyer' )", crate::empty_params(), ) @@ -1360,10 +1357,10 @@ mod tests { assert_eq!(status, "needs_review"); connection .execute( - "UPDATE orders SET workflow_agreement = 'agreed_pending_validation' WHERE id = 'order_status'", + "UPDATE orders SET workflow_agreement = 'contested' WHERE id = 'order_status'", crate::empty_params(), ) - .expect("agreed pending validation agreement should satisfy current check"); + .expect("contested agreement should satisfy current check"); } fn table_exists(connection: &AppSqliteDatabase, table_name: &str) -> bool { diff --git a/crates/store/src/migrations.rs b/crates/store/src/migrations.rs @@ -126,6 +126,10 @@ const MIGRATIONS: &[Migration] = &[ version: 30, sql: include_str!("../migrations/0030_buyer_order_public_note_confirmation.sql"), }, + Migration { + version: 31, + sql: include_str!("../migrations/0031_drop_default_buyer_private_fields.sql"), + }, ]; pub fn latest_schema_version() -> u32 { diff --git a/crates/store/src/repo/buyer.rs b/crates/store/src/repo/buyer.rs @@ -90,10 +90,6 @@ pub struct BuyerOrderRuntimeStoreExport { pub status: String, pub buyer_context_key: String, pub buyer_name: String, - pub buyer_email: String, - pub buyer_phone: String, - pub buyer_order_note: String, - pub buyer_order_note_public_confirmed: bool, pub updated_at: String, pub fulfillment_window_id: Option<FulfillmentWindowId>, pub fulfillment_window_label: Option<String>, @@ -310,7 +306,6 @@ impl<'a> AppBuyerRepository<'a> { "update buyer_carts set farm_id = null, - buyer_order_note = '', updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') where buyer_context_key = ?1", crate::app_sqlite_params![context_key.as_str()], @@ -382,20 +377,9 @@ impl<'a> AppBuyerRepository<'a> { "update buyer_carts set buyer_name = ?2, - buyer_email = ?3, - buyer_phone = ?4, - buyer_order_note = ?5, - buyer_order_note_public_confirmed = ?6, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') where buyer_context_key = ?1", - crate::app_sqlite_params![ - context_key.as_str(), - draft.name.trim(), - draft.email.trim(), - draft.phone.trim(), - draft.order_note.trim(), - draft.confirm_public_note, - ], + crate::app_sqlite_params![context_key.as_str(), draft.name.trim()], ) .map_err(|source| AppSqliteError::Query { operation: "save buyer order review draft", @@ -447,11 +431,7 @@ impl<'a> AppBuyerRepository<'a> { customer_display_name, status, updated_at, - buyer_context_key, - buyer_email, - buyer_phone, - buyer_order_note, - buyer_order_note_public_confirmed + buyer_context_key ) values ( ?1, ?2, @@ -460,11 +440,7 @@ impl<'a> AppBuyerRepository<'a> { ?5, 'needs_action', strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), - ?6, - ?7, - ?8, - ?9, - ?10 + ?6 )", crate::app_sqlite_params![ order_id.to_string(), @@ -473,10 +449,6 @@ impl<'a> AppBuyerRepository<'a> { order_number, order_review.draft.name.trim(), context_key.as_str(), - order_review.draft.email.trim(), - order_review.draft.phone.trim(), - order_review.draft.order_note.trim(), - order_review.draft.confirm_public_note, ], ) .map_err(|source| AppSqliteError::Query { @@ -543,8 +515,6 @@ impl<'a> AppBuyerRepository<'a> { "update buyer_carts set farm_id = null, - buyer_order_note = '', - buyer_order_note_public_confirmed = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') where buyer_context_key = ?1", crate::app_sqlite_params![context_key.as_str()], @@ -954,7 +924,6 @@ impl<'a> AppBuyerRepository<'a> { o.farm_id, o.order_number, o.status, - o.buyer_order_note, o.workflow_revision, o.workflow_agreement, o.workflow_inventory, @@ -984,12 +953,11 @@ impl<'a> AppBuyerRepository<'a> { row.try_get::<String, _>(5)?, row.try_get::<String, _>(6)?, row.try_get::<String, _>(7)?, - row.try_get::<String, _>(8)?, - row.try_get::<Option<String>, _>(9)?, - row.try_get::<String, _>(10)?, + row.try_get::<Option<String>, _>(8)?, + row.try_get::<String, _>(9)?, + row.try_get::<Option<String>, _>(10)?, row.try_get::<Option<String>, _>(11)?, row.try_get::<Option<String>, _>(12)?, - row.try_get::<Option<String>, _>(13)?, )) }) .optional() @@ -1005,7 +973,6 @@ impl<'a> AppBuyerRepository<'a> { farm_id, order_number, status, - order_note, workflow_revision, workflow_agreement, workflow_inventory, @@ -1050,7 +1017,7 @@ impl<'a> AppBuyerRepository<'a> { economics, workflow, validation_receipts, - order_note: empty_string_to_none(order_note), + order_note: None, repeat_demand: self.build_repeat_demand_handoff( order_id, farm_id, @@ -1079,10 +1046,6 @@ impl<'a> AppBuyerRepository<'a> { o.status, o.buyer_context_key, o.customer_display_name, - o.buyer_email, - o.buyer_phone, - o.buyer_order_note, - o.buyer_order_note_public_confirmed, o.updated_at, f.display_name, fw.id, @@ -1105,14 +1068,10 @@ impl<'a> AppBuyerRepository<'a> { row.try_get::<String, _>(5)?, row.try_get::<String, _>(6)?, row.try_get::<String, _>(7)?, - row.try_get::<String, _>(8)?, - row.try_get::<bool, _>(9)?, - row.try_get::<String, _>(10)?, - row.try_get::<String, _>(11)?, - row.try_get::<Option<String>, _>(12)?, - row.try_get::<Option<String>, _>(13)?, - row.try_get::<Option<String>, _>(14)?, - row.try_get::<Option<String>, _>(15)?, + row.try_get::<Option<String>, _>(8)?, + row.try_get::<Option<String>, _>(9)?, + row.try_get::<Option<String>, _>(10)?, + row.try_get::<Option<String>, _>(11)?, )) }, ) @@ -1131,10 +1090,6 @@ impl<'a> AppBuyerRepository<'a> { status, buyer_context_key, buyer_name, - buyer_email, - buyer_phone, - buyer_order_note, - buyer_order_note_public_confirmed, updated_at, farm_display_name, fulfillment_window_id, @@ -1154,10 +1109,6 @@ impl<'a> AppBuyerRepository<'a> { status, buyer_context_key: buyer_context_key.unwrap_or_else(|| context_key.clone()), buyer_name, - buyer_email, - buyer_phone, - buyer_order_note, - buyer_order_note_public_confirmed, updated_at, fulfillment_window_id: parse_optional_typed_id( "orders.fulfillment_window_id", @@ -1582,11 +1533,7 @@ impl<'a> AppBuyerRepository<'a> { .query_row( "select farm_id, - buyer_name, - buyer_email, - buyer_phone, - buyer_order_note, - buyer_order_note_public_confirmed + buyer_name from buyer_carts where buyer_context_key = ?1 limit 1", @@ -1595,10 +1542,6 @@ impl<'a> AppBuyerRepository<'a> { Ok(( row.try_get::<Option<String>, _>(0)?, row.try_get::<String, _>(1)?, - row.try_get::<String, _>(2)?, - row.try_get::<String, _>(3)?, - row.try_get::<String, _>(4)?, - row.try_get::<bool, _>(5)?, )) }, ) @@ -1607,25 +1550,12 @@ impl<'a> AppBuyerRepository<'a> { operation: "load buyer cart header", source, })? - .map( - |( - farm_id, + .map(|(farm_id, buyer_name)| { + Ok(BuyerCartHeader { + farm_id: parse_optional_typed_id("buyer_carts.farm_id", farm_id)?, buyer_name, - buyer_email, - buyer_phone, - buyer_order_note, - buyer_order_note_public_confirmed, - )| { - Ok(BuyerCartHeader { - farm_id: parse_optional_typed_id("buyer_carts.farm_id", farm_id)?, - buyer_name, - buyer_email, - buyer_phone, - buyer_order_note, - buyer_order_note_public_confirmed, - }) - }, - ) + }) + }) .transpose() } @@ -2230,20 +2160,16 @@ impl<'a> AppBuyerRepository<'a> { struct BuyerCartHeader { farm_id: Option<FarmId>, buyer_name: String, - buyer_email: String, - buyer_phone: String, - buyer_order_note: String, - buyer_order_note_public_confirmed: bool, } impl BuyerCartHeader { fn into_order_review_draft(self) -> BuyerOrderReviewDraft { BuyerOrderReviewDraft { name: self.buyer_name, - email: self.buyer_email, - phone: self.buyer_phone, - order_note: self.buyer_order_note, - confirm_public_note: self.buyer_order_note_public_confirmed, + email: String::new(), + phone: String::new(), + order_note: String::new(), + confirm_public_note: false, } } } @@ -2678,12 +2604,6 @@ fn buyer_order_review_disabled_reason( if draft.name.trim().is_empty() { return Some(BuyerOrderReviewDisabledReason::MissingName); } - if draft.email.trim().is_empty() { - return Some(BuyerOrderReviewDisabledReason::MissingEmail); - } - if !draft.order_note.trim().is_empty() && !draft.confirm_public_note { - return Some(BuyerOrderReviewDisabledReason::PublicNoteConfirmationRequired); - } if matches!(context, BuyerContext::Guest) { return Some(BuyerOrderReviewDisabledReason::AccountRequired); } @@ -3002,7 +2922,7 @@ mod tests { "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; #[test] - fn buyer_order_review_requires_public_note_confirmation() { + fn buyer_order_review_does_not_require_private_note_confirmation_in_default_store() { let cart = BuyerCartProjection { farm_id: Some(FarmId::generate()), farm_display_name: Some("Willow Farm".to_owned()), @@ -3039,7 +2959,7 @@ mod tests { Some(&"Friday pickup".to_owned()), &draft, ), - Some(BuyerOrderReviewDisabledReason::PublicNoteConfirmationRequired) + None ); assert_eq!( super::buyer_order_review_disabled_reason( @@ -4235,9 +4155,9 @@ mod tests { order_number: &str, status: &str, buyer_context_key: Option<&str>, - buyer_email: &str, - buyer_phone: &str, - buyer_order_note: &str, + _buyer_email: &str, + _buyer_phone: &str, + _buyer_order_note: &str, ) { connection .execute( @@ -4249,20 +4169,14 @@ mod tests { customer_display_name, status, updated_at, - buyer_context_key, - buyer_email, - buyer_phone, - buyer_order_note - ) values (?1, ?2, null, ?3, 'Casey', ?4, '2026-04-20T10:00:00Z', ?5, ?6, ?7, ?8)", + buyer_context_key + ) values (?1, ?2, null, ?3, 'Casey', ?4, '2026-04-20T10:00:00Z', ?5)", crate::app_sqlite_params![ order_id.to_string(), farm_id.to_string(), order_number, status, buyer_context_key, - buyer_email, - buyer_phone, - buyer_order_note, ], ) .expect("order insert should succeed"); diff --git a/crates/store/src/repo/orders.rs b/crates/store/src/repo/orders.rs @@ -7,7 +7,7 @@ use radroots_studio_app_view::{ PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry, PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, - PackDayScreenQueryState, ProductId, TradeAgreementStatus, TradeWorkflowProjection, + PackDayScreenQueryState, ProductId, TradeWorkflowProjection, }; use sqlx::Row; @@ -1177,14 +1177,9 @@ fn summarize_orders(records: &[OrderRecord]) -> OrdersListSummary { fn primary_action_for_order( status: OrderStatus, - workflow: &TradeWorkflowProjection, + _workflow: &TradeWorkflowProjection, ) -> Option<OrderPrimaryAction> { match status { - OrderStatus::NeedsAction - if workflow.agreement == TradeAgreementStatus::AgreedPendingValidation => - { - None - } OrderStatus::NeedsAction => Some(OrderPrimaryAction::Review), OrderStatus::Scheduled | OrderStatus::Packed diff --git a/crates/store/src/repo/workflow.rs b/crates/store/src/repo/workflow.rs @@ -1,5 +1,6 @@ use radroots_studio_app_view::{ - OrderId, TradeAgreementStatus, TradeEconomicsProjection, TradeInventoryStatus, + OrderId, TradeAgreementStatus, TradeAttestationStatus, TradeConflictStatus, + TradeEconomicsProjection, TradeEvidenceStatus, TradeInventoryStatus, TradePrivateTermsStatus, TradeProvenanceProjection, TradeRevisionStatus, TradeWorkflowProjection, TradeWorkflowSource, }; @@ -25,6 +26,11 @@ pub(super) fn trade_workflow_projection_from_storage( revision: snapshot.revision, economics: snapshot.economics, inventory: parse_trade_inventory_status("orders.workflow_inventory", snapshot.inventory)?, + evidence: TradeEvidenceStatus::Missing, + conflict: TradeConflictStatus::None, + private_terms: TradePrivateTermsStatus::NotRequired, + attestation: TradeAttestationStatus::None, + projection_digest: None, provenance: TradeProvenanceProjection::from_primary_source(parse_trade_workflow_source( "orders.workflow_provenance_source", snapshot.provenance_source, @@ -39,11 +45,10 @@ fn parse_trade_agreement_status( ) -> Result<TradeAgreementStatus, AppSqliteError> { match value.as_str() { "requested" => Ok(TradeAgreementStatus::Requested), - "agreed_pending_validation" => Ok(TradeAgreementStatus::AgreedPendingValidation), "committed" => Ok(TradeAgreementStatus::Committed), + "contested" => Ok(TradeAgreementStatus::Contested), "declined" => Ok(TradeAgreementStatus::Declined), "cancelled" => Ok(TradeAgreementStatus::Cancelled), - "validation_expired" => Ok(TradeAgreementStatus::ValidationExpired), "invalid" => Ok(TradeAgreementStatus::Invalid), _ => Err(AppSqliteError::DecodeEnum { field, value }), } diff --git a/crates/sync/src/lib.rs b/crates/sync/src/lib.rs @@ -3,11 +3,11 @@ mod publish; pub use publish::{ - AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, - AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, AppOrderDecisionPublishPayload, - AppOrderRequestItemPayload, AppOrderRequestPublishPayload, AppPublishContext, - AppPublishPayload, AppPublishPayloadJsonError, AppPublishValidationFailure, - AppPublishValidationFailureSet, AppPublishWorkKind, + AppFarmProfilePublishPayload, AppListingPublishPayload, AppPublishContext, AppPublishPayload, + AppPublishPayloadJsonError, AppPublishValidationFailure, AppPublishValidationFailureSet, + AppPublishWorkKind, AppTradeCancellationPublishPayload, + AppTradeCandidateDecisionPublishPayload, AppTradeProposalPublishPayload, + AppTradeRevisionProposalPublishPayload, }; use radroots_studio_app_view::{FarmId, FulfillmentWindowId, OrderId, ProductId}; diff --git a/crates/sync/src/publish.rs b/crates/sync/src/publish.rs @@ -1,6 +1,8 @@ +use radroots_event::trade::{RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1}; use radroots_sdk::{ - FARM_PUBLISH_OPERATION_KIND, LISTING_PUBLISH_OPERATION_KIND, TRADE_CANCELLATION_OPERATION_KIND, - TRADE_DECISION_OPERATION_KIND, TRADE_SUBMIT_OPERATION_KIND, + FARM_PUBLISH_OPERATION_KIND, LISTING_PUBLISH_OPERATION_KIND, TRADE_CANCEL_OPERATION_KIND, + TRADE_DECIDE_CANDIDATE_OPERATION_KIND, TRADE_PROPOSE_REVISION_OPERATION_KIND, + TRADE_SUBMIT_PROPOSAL_OPERATION_KIND, }; use radroots_studio_app_view::{ FarmId, FarmReadiness, FulfillmentWindowId, OrderId, ProductId, ProductStatus, @@ -15,9 +17,10 @@ use crate::{PendingSyncOperation, PendingSyncOperationState, SyncAggregateRef, S pub enum AppPublishWorkKind { FarmProfile, Listing, - OrderRequest, - OrderDecision, - OrderCancellation, + TradeProposal, + TradeRevisionProposal, + TradeCandidateDecision, + TradeCancellation, } impl AppPublishWorkKind { @@ -25,9 +28,10 @@ impl AppPublishWorkKind { match self { Self::FarmProfile => "farm_profile", Self::Listing => "listing", - Self::OrderRequest => "order_request", - Self::OrderDecision => "order_decision", - Self::OrderCancellation => "order_cancellation", + Self::TradeProposal => "trade_proposal", + Self::TradeRevisionProposal => "trade_revision_proposal", + Self::TradeCandidateDecision => "trade_candidate_decision", + Self::TradeCancellation => "trade_cancellation", } } @@ -35,9 +39,10 @@ impl AppPublishWorkKind { match self { Self::FarmProfile => FARM_PUBLISH_OPERATION_KIND, Self::Listing => LISTING_PUBLISH_OPERATION_KIND, - Self::OrderRequest => TRADE_SUBMIT_OPERATION_KIND, - Self::OrderDecision => TRADE_DECISION_OPERATION_KIND, - Self::OrderCancellation => TRADE_CANCELLATION_OPERATION_KIND, + Self::TradeProposal => TRADE_SUBMIT_PROPOSAL_OPERATION_KIND, + Self::TradeRevisionProposal => TRADE_PROPOSE_REVISION_OPERATION_KIND, + Self::TradeCandidateDecision => TRADE_DECIDE_CANDIDATE_OPERATION_KIND, + Self::TradeCancellation => TRADE_CANCEL_OPERATION_KIND, } } } @@ -106,83 +111,36 @@ pub struct AppListingPublishPayload { } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct AppOrderRequestItemPayload { - pub product_id: ProductId, - pub quantity: u32, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct AppOrderRequestPublishPayload { +pub struct AppTradeProposalPublishPayload { pub context: AppPublishContext, pub order_id: OrderId, pub farm_id: FarmId, - pub status: Option<String>, - pub order_document_json: Option<serde_json::Value>, - pub listing_addr: Option<String>, - pub listing_event_id: Option<String>, - pub listing_relays: Vec<String>, - pub buyer_pubkey: Option<String>, - pub seller_pubkey: Option<String>, - pub items: Vec<AppOrderRequestItemPayload>, - pub currency_code: Option<String>, - pub total_minor_units: Option<u32>, - pub note: Option<String>, - pub confirm_public_note: bool, + pub envelope: RadrootsTradeMutationEnvelopeV1, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct AppOrderDecisionInventoryCommitment { - pub bin_id: String, - pub bin_count: u32, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", tag = "decision")] -pub enum AppOrderDecisionPayload { - Accepted { - inventory_commitments: Vec<AppOrderDecisionInventoryCommitment>, - }, - Declined { - reason: String, - }, -} - -impl AppOrderDecisionPayload { - pub const fn storage_key(&self) -> &'static str { - match self { - Self::Accepted { .. } => "accepted", - Self::Declined { .. } => "declined", - } - } +pub struct AppTradeRevisionProposalPublishPayload { + pub context: AppPublishContext, + pub order_id: OrderId, + pub farm_id: FarmId, + pub envelope: RadrootsTradeMutationEnvelopeV1, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct AppOrderDecisionPublishPayload { +pub struct AppTradeCandidateDecisionPublishPayload { pub context: AppPublishContext, - pub app_order_id: OrderId, + pub order_id: OrderId, pub farm_id: FarmId, - pub trade_order_id: String, - pub request_event_id: String, - pub listing_event_id: Option<String>, - pub listing_addr: String, - pub buyer_pubkey: String, - pub seller_pubkey: String, - pub decision: AppOrderDecisionPayload, - pub confirm_public_note: bool, + pub envelope: RadrootsTradeMutationEnvelopeV1, + pub acknowledge_private_terms: bool, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct AppOrderCancellationPublishPayload { +pub struct AppTradeCancellationPublishPayload { pub context: AppPublishContext, - pub app_order_id: OrderId, + pub order_id: OrderId, pub farm_id: FarmId, - pub trade_order_id: String, - pub request_event_id: String, - pub listing_addr: String, - pub buyer_pubkey: String, - pub seller_pubkey: String, - pub reason: String, - pub confirm_public_note: bool, + pub envelope: RadrootsTradeMutationEnvelopeV1, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -190,9 +148,10 @@ pub struct AppOrderCancellationPublishPayload { pub enum AppPublishPayload { FarmProfile(AppFarmProfilePublishPayload), Listing(AppListingPublishPayload), - OrderRequest(AppOrderRequestPublishPayload), - OrderDecision(AppOrderDecisionPublishPayload), - OrderCancellation(AppOrderCancellationPublishPayload), + TradeProposal(AppTradeProposalPublishPayload), + TradeRevisionProposal(AppTradeRevisionProposalPublishPayload), + TradeCandidateDecision(AppTradeCandidateDecisionPublishPayload), + TradeCancellation(AppTradeCancellationPublishPayload), } impl AppPublishPayload { @@ -200,9 +159,10 @@ impl AppPublishPayload { match self { Self::FarmProfile(_) => AppPublishWorkKind::FarmProfile, Self::Listing(_) => AppPublishWorkKind::Listing, - Self::OrderRequest(_) => AppPublishWorkKind::OrderRequest, - Self::OrderDecision(_) => AppPublishWorkKind::OrderDecision, - Self::OrderCancellation(_) => AppPublishWorkKind::OrderCancellation, + Self::TradeProposal(_) => AppPublishWorkKind::TradeProposal, + Self::TradeRevisionProposal(_) => AppPublishWorkKind::TradeRevisionProposal, + Self::TradeCandidateDecision(_) => AppPublishWorkKind::TradeCandidateDecision, + Self::TradeCancellation(_) => AppPublishWorkKind::TradeCancellation, } } @@ -214,9 +174,10 @@ impl AppPublishPayload { match self { Self::FarmProfile(payload) => SyncAggregateRef::Farm(payload.farm_id), Self::Listing(payload) => SyncAggregateRef::Product(payload.product_id), - Self::OrderRequest(payload) => SyncAggregateRef::Order(payload.order_id), - Self::OrderDecision(payload) => SyncAggregateRef::Order(payload.app_order_id), - Self::OrderCancellation(payload) => SyncAggregateRef::Order(payload.app_order_id), + Self::TradeProposal(payload) => SyncAggregateRef::Order(payload.order_id), + Self::TradeRevisionProposal(payload) => SyncAggregateRef::Order(payload.order_id), + Self::TradeCandidateDecision(payload) => SyncAggregateRef::Order(payload.order_id), + Self::TradeCancellation(payload) => SyncAggregateRef::Order(payload.order_id), } } @@ -291,110 +252,37 @@ impl AppPublishPayload { failures.push(AppPublishValidationFailure::MissingListingFulfillmentLocation); } } - Self::OrderRequest(payload) => { + Self::TradeProposal(payload) => { payload.context.validation_failures(&mut failures); - if payload.order_document_json.is_none() { - failures.push(AppPublishValidationFailure::MissingOrderDocument); - } - if payload - .listing_addr - .as_deref() - .is_none_or(|value| value.trim().is_empty()) - { - failures.push(AppPublishValidationFailure::MissingOrderListingAddress); - } - if payload - .listing_event_id - .as_deref() - .is_none_or(|value| value.trim().is_empty()) - { - failures.push(AppPublishValidationFailure::MissingOrderListingEventId); - } - if payload - .listing_relays - .iter() - .all(|relay| relay.trim().is_empty()) - { - failures.push(AppPublishValidationFailure::MissingOrderListingRelay); - } - if payload - .buyer_pubkey - .as_deref() - .is_none_or(|value| value.trim().is_empty()) - { - failures.push(AppPublishValidationFailure::MissingOrderBuyerPubkey); - } - if payload - .seller_pubkey - .as_deref() - .is_none_or(|value| value.trim().is_empty()) - { - failures.push(AppPublishValidationFailure::MissingOrderSellerPubkey); - } - if payload.items.is_empty() || payload.items.iter().any(|item| item.quantity == 0) { - failures.push(AppPublishValidationFailure::MissingOrderItems); - } - if payload - .currency_code - .as_deref() - .is_none_or(|value| value.trim().is_empty()) - { - failures.push(AppPublishValidationFailure::MissingOrderCurrency); - } - if payload.total_minor_units.is_none() { - failures.push(AppPublishValidationFailure::MissingOrderTotal); - } + validate_trade_envelope_kind( + &payload.envelope, + TradeMutationPayloadKind::Proposal, + &mut failures, + ); } - Self::OrderDecision(payload) => { + Self::TradeRevisionProposal(payload) => { payload.context.validation_failures(&mut failures); - if payload.trade_order_id.trim().is_empty() { - failures.push(AppPublishValidationFailure::MissingOrderTradeOrderId); - } - if payload.request_event_id.trim().is_empty() { - failures.push(AppPublishValidationFailure::MissingOrderRequestEventId); - } - if payload.listing_addr.trim().is_empty() { - failures.push(AppPublishValidationFailure::MissingOrderListingAddress); - } - if payload.buyer_pubkey.trim().is_empty() { - failures.push(AppPublishValidationFailure::MissingOrderBuyerPubkey); - } - if payload.seller_pubkey.trim().is_empty() { - failures.push(AppPublishValidationFailure::MissingOrderSellerPubkey); - } - match &payload.decision { - AppOrderDecisionPayload::Accepted { - inventory_commitments, - } => { - if inventory_commitments.is_empty() - || inventory_commitments.iter().any(|commitment| { - commitment.bin_id.trim().is_empty() || commitment.bin_count == 0 - }) - { - failures - .push(AppPublishValidationFailure::MissingOrderDecisionInventory); - } - } - AppOrderDecisionPayload::Declined { reason } => { - if reason.trim().is_empty() { - failures.push(AppPublishValidationFailure::MissingOrderDeclineReason); - } - } - } + validate_trade_envelope_kind( + &payload.envelope, + TradeMutationPayloadKind::RevisionProposal, + &mut failures, + ); } - Self::OrderCancellation(payload) => { - validate_lifecycle_order_fields( - &payload.context, - payload.trade_order_id.as_str(), - payload.request_event_id.as_str(), - payload.listing_addr.as_str(), - payload.buyer_pubkey.as_str(), - payload.seller_pubkey.as_str(), + Self::TradeCandidateDecision(payload) => { + payload.context.validation_failures(&mut failures); + validate_trade_envelope_kind( + &payload.envelope, + TradeMutationPayloadKind::CandidateDecision, + &mut failures, + ); + } + Self::TradeCancellation(payload) => { + payload.context.validation_failures(&mut failures); + validate_trade_envelope_kind( + &payload.envelope, + TradeMutationPayloadKind::Cancellation, &mut failures, ); - if payload.reason.trim().is_empty() { - failures.push(AppPublishValidationFailure::MissingOrderCancellationReason); - } } } @@ -417,30 +305,36 @@ impl AppPublishPayload { } } -fn validate_lifecycle_order_fields( - context: &AppPublishContext, - trade_order_id: &str, - request_event_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TradeMutationPayloadKind { + Proposal, + RevisionProposal, + CandidateDecision, + Cancellation, +} + +fn validate_trade_envelope_kind( + envelope: &RadrootsTradeMutationEnvelopeV1, + expected: TradeMutationPayloadKind, failures: &mut Vec<AppPublishValidationFailure>, ) { - context.validation_failures(failures); - if trade_order_id.trim().is_empty() { - failures.push(AppPublishValidationFailure::MissingOrderTradeOrderId); - } - if request_event_id.trim().is_empty() { - failures.push(AppPublishValidationFailure::MissingOrderRequestEventId); - } - if listing_addr.trim().is_empty() { - failures.push(AppPublishValidationFailure::MissingOrderListingAddress); + if envelope.validate().is_err() { + failures.push(AppPublishValidationFailure::InvalidTradeMutationEnvelope); + return; } - if buyer_pubkey.trim().is_empty() { - failures.push(AppPublishValidationFailure::MissingOrderBuyerPubkey); - } - if seller_pubkey.trim().is_empty() { - failures.push(AppPublishValidationFailure::MissingOrderSellerPubkey); + let actual = match &envelope.body { + RadrootsTradeMutationBodyV1::Proposal { .. } => TradeMutationPayloadKind::Proposal, + RadrootsTradeMutationBodyV1::RevisionProposal { .. } => { + TradeMutationPayloadKind::RevisionProposal + } + RadrootsTradeMutationBodyV1::Decision { .. } + | RadrootsTradeMutationBodyV1::RevisionDecision { .. } => { + TradeMutationPayloadKind::CandidateDecision + } + RadrootsTradeMutationBodyV1::Cancellation { .. } => TradeMutationPayloadKind::Cancellation, + }; + if actual != expected { + failures.push(AppPublishValidationFailure::WrongTradeMutationKind); } } @@ -461,20 +355,8 @@ pub enum AppPublishValidationFailure { MissingListingStock, MissingListingFulfillmentMethod, MissingListingFulfillmentLocation, - MissingOrderDocument, - MissingOrderListingAddress, - MissingOrderListingEventId, - MissingOrderListingRelay, - MissingOrderBuyerPubkey, - MissingOrderSellerPubkey, - MissingOrderItems, - MissingOrderCurrency, - MissingOrderTotal, - MissingOrderTradeOrderId, - MissingOrderRequestEventId, - MissingOrderDecisionInventory, - MissingOrderDeclineReason, - MissingOrderCancellationReason, + InvalidTradeMutationEnvelope, + WrongTradeMutationKind, } impl AppPublishValidationFailure { @@ -494,20 +376,8 @@ impl AppPublishValidationFailure { Self::MissingListingStock => "missing_listing_stock", Self::MissingListingFulfillmentMethod => "missing_listing_fulfillment_method", Self::MissingListingFulfillmentLocation => "missing_listing_fulfillment_location", - Self::MissingOrderDocument => "missing_order_document", - Self::MissingOrderListingAddress => "missing_order_listing_address", - Self::MissingOrderListingEventId => "missing_order_listing_event_id", - Self::MissingOrderListingRelay => "missing_order_listing_relay", - Self::MissingOrderBuyerPubkey => "missing_order_buyer_pubkey", - Self::MissingOrderSellerPubkey => "missing_order_seller_pubkey", - Self::MissingOrderItems => "missing_order_items", - Self::MissingOrderCurrency => "missing_order_currency", - Self::MissingOrderTotal => "missing_order_total", - Self::MissingOrderTradeOrderId => "missing_order_trade_order_id", - Self::MissingOrderRequestEventId => "missing_order_request_event_id", - Self::MissingOrderDecisionInventory => "missing_order_decision_inventory", - Self::MissingOrderDeclineReason => "missing_order_decline_reason", - Self::MissingOrderCancellationReason => "missing_order_cancellation_reason", + Self::InvalidTradeMutationEnvelope => "invalid_trade_mutation_envelope", + Self::WrongTradeMutationKind => "wrong_trade_mutation_kind", } } } @@ -559,15 +429,31 @@ impl PendingSyncOperation { #[cfg(test)] mod tests { use super::{ - AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, - AppOrderDecisionPayload, AppOrderDecisionPublishPayload, AppOrderRequestItemPayload, - AppOrderRequestPublishPayload, AppPublishContext, AppPublishPayload, - AppPublishValidationFailure, AppPublishWorkKind, FARM_PUBLISH_OPERATION_KIND, - TRADE_CANCELLATION_OPERATION_KIND, TRADE_DECISION_OPERATION_KIND, + AppFarmProfilePublishPayload, AppListingPublishPayload, AppPublishContext, + AppPublishPayload, AppPublishValidationFailure, AppPublishWorkKind, + AppTradeCancellationPublishPayload, AppTradeCandidateDecisionPublishPayload, + AppTradeProposalPublishPayload, AppTradeRevisionProposalPublishPayload, + FARM_PUBLISH_OPERATION_KIND, TRADE_CANCEL_OPERATION_KIND, + TRADE_DECIDE_CANDIDATE_OPERATION_KIND, TRADE_PROPOSE_REVISION_OPERATION_KIND, }; use crate::{ PendingSyncOperation, PendingSyncOperationState, SyncAggregateRef, SyncOperationKind, }; + use radroots_event::{ + ids::{ + RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, + RadrootsPublicKey, RadrootsTradeId, + }, + trade::{ + RADROOTS_TRADE_CANCELLATION_CONTRACT_ID, RADROOTS_TRADE_DECISION_CONTRACT_ID, + RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, + RADROOTS_TRADE_SCHEMA_VERSION, RadrootsFulfillmentProfileV1, + RadrootsTradeCancellationProfileV1, RadrootsTradeCandidateLineV1, + RadrootsTradeCandidateTermsV1, RadrootsTradeDecisionV1, + RadrootsTradeEconomicsProfileV1, RadrootsTradeMutationBodyV1, + RadrootsTradeMutationEnvelopeV1, canonical_trade_mutation_content, + }, + }; use radroots_studio_app_view::{FarmId, FarmReadiness, OrderId, ProductId, ProductStatus}; #[test] @@ -609,17 +495,19 @@ mod tests { let work_kinds = [ AppPublishWorkKind::FarmProfile, AppPublishWorkKind::Listing, - AppPublishWorkKind::OrderRequest, - AppPublishWorkKind::OrderDecision, - AppPublishWorkKind::OrderCancellation, + AppPublishWorkKind::TradeProposal, + AppPublishWorkKind::TradeRevisionProposal, + AppPublishWorkKind::TradeCandidateDecision, + AppPublishWorkKind::TradeCancellation, ]; - assert_eq!(work_kinds.len(), 5); + assert_eq!(work_kinds.len(), 6); assert_eq!(work_kinds[0].storage_key(), "farm_profile"); assert_eq!(work_kinds[1].storage_key(), "listing"); - assert_eq!(work_kinds[2].storage_key(), "order_request"); - assert_eq!(work_kinds[3].storage_key(), "order_decision"); - assert_eq!(work_kinds[4].storage_key(), "order_cancellation"); + assert_eq!(work_kinds[2].storage_key(), "trade_proposal"); + assert_eq!(work_kinds[3].storage_key(), "trade_revision_proposal"); + assert_eq!(work_kinds[4].storage_key(), "trade_candidate_decision"); + assert_eq!(work_kinds[5].storage_key(), "trade_cancellation"); } #[test] @@ -673,73 +561,108 @@ mod tests { } #[test] - fn order_request_publish_payload_requires_sdk_publish_inputs() { - let payload = AppPublishPayload::OrderRequest(AppOrderRequestPublishPayload { - context: AppPublishContext::new("acct_buyer", "place_personal_order"), - order_id: OrderId::generate(), + fn trade_proposal_publish_payload_uses_sdk_envelope() { + let order_id = OrderId::generate(); + let proposal = proposal_envelope(); + let payload = AppPublishPayload::TradeProposal(AppTradeProposalPublishPayload { + context: AppPublishContext::new("acct_buyer", "trade_proposal"), + order_id, farm_id: FarmId::generate(), - status: Some("needs_action".to_owned()), - order_document_json: None, - listing_addr: Some(String::new()), - listing_event_id: None, - listing_relays: vec![], - buyer_pubkey: None, - seller_pubkey: Some(" ".to_owned()), - items: vec![AppOrderRequestItemPayload { - product_id: ProductId::generate(), - quantity: 0, - }], - currency_code: None, - total_minor_units: None, - note: None, - confirm_public_note: false, + envelope: proposal.clone(), }); - let reason_codes: Vec<&str> = payload - .validation_failures() - .into_iter() - .map(AppPublishValidationFailure::storage_key) - .collect(); + assert_eq!(payload.validation_failures(), Vec::new()); + assert_eq!(payload.work_kind().storage_key(), "trade_proposal"); + assert_eq!( + payload.work_kind().sdk_operation(), + super::TRADE_SUBMIT_PROPOSAL_OPERATION_KIND + ); + let operation = + PendingSyncOperation::from_publish_payload(payload.clone(), "2026-04-20T18:00:00Z") + .expect("typed trade payload should serialize"); + + assert_eq!(operation.aggregate, SyncAggregateRef::Order(order_id)); + assert_eq!(operation.operation_key, format!("order:{order_id}:upsert")); assert_eq!( - reason_codes, - vec![ - "missing_order_document", - "missing_order_listing_address", - "missing_order_listing_event_id", - "missing_order_listing_relay", - "missing_order_buyer_pubkey", - "missing_order_seller_pubkey", - "missing_order_items", - "missing_order_currency", - "missing_order_total", - ] + operation.publish_payload().expect("payload should parse"), + AppPublishPayload::TradeProposal(AppTradeProposalPublishPayload { + context: AppPublishContext::new("acct_buyer", "trade_proposal"), + order_id, + farm_id: match payload { + AppPublishPayload::TradeProposal(payload) => payload.farm_id, + _ => unreachable!(), + }, + envelope: proposal, + }) ); } #[test] - fn order_decision_publish_payload_reports_stable_validation_reason_codes() { - let payload = AppPublishPayload::OrderDecision(AppOrderDecisionPublishPayload { + fn trade_revision_and_decision_payloads_validate_expected_mutation_kind() { + let order_id = OrderId::generate(); + let farm_id = FarmId::generate(); + let proposal = proposal_envelope(); + let revision = revision_proposal_envelope(&proposal); + let decision = decision_envelope(&revision); + + let revision_payload = + AppPublishPayload::TradeRevisionProposal(AppTradeRevisionProposalPublishPayload { + context: AppPublishContext::new("acct_buyer", "trade_revision_proposal"), + order_id, + farm_id, + envelope: revision, + }); + assert_eq!(revision_payload.validation_failures(), Vec::new()); + assert_eq!( + revision_payload.work_kind().sdk_operation(), + TRADE_PROPOSE_REVISION_OPERATION_KIND + ); + + let decision_payload = + AppPublishPayload::TradeCandidateDecision(AppTradeCandidateDecisionPublishPayload { + context: AppPublishContext::new("acct_seller", "trade_candidate_decision"), + order_id, + farm_id, + envelope: decision, + acknowledge_private_terms: true, + }); + assert_eq!(decision_payload.validation_failures(), Vec::new()); + assert_eq!( + decision_payload.work_kind().sdk_operation(), + TRADE_DECIDE_CANDIDATE_OPERATION_KIND + ); + + let wrong_kind = + AppPublishPayload::TradeCandidateDecision(AppTradeCandidateDecisionPublishPayload { + context: AppPublishContext::new("acct_seller", "trade_candidate_decision"), + order_id, + farm_id, + envelope: proposal_envelope(), + acknowledge_private_terms: false, + }); + + assert_eq!( + wrong_kind + .validation_failures() + .into_iter() + .map(AppPublishValidationFailure::storage_key) + .collect::<Vec<_>>(), + vec!["wrong_trade_mutation_kind"] + ); + } + + #[test] + fn trade_payload_reports_stable_invalid_envelope_reason_code() { + let mut envelope = proposal_envelope(); + envelope.contract_id = RADROOTS_TRADE_DECISION_CONTRACT_ID.to_owned(); + let payload = AppPublishPayload::TradeProposal(AppTradeProposalPublishPayload { context: AppPublishContext::new("", ""), - app_order_id: OrderId::generate(), + order_id: OrderId::generate(), farm_id: FarmId::generate(), - trade_order_id: " ".to_owned(), - request_event_id: String::new(), - listing_event_id: None, - listing_addr: String::new(), - buyer_pubkey: String::new(), - seller_pubkey: String::new(), - decision: AppOrderDecisionPayload::Declined { - reason: " ".to_owned(), - }, - confirm_public_note: false, + envelope, }); - assert_eq!(payload.work_kind().storage_key(), "order_decision"); - assert_eq!( - payload.work_kind().sdk_operation(), - TRADE_DECISION_OPERATION_KIND - ); let reason_codes: Vec<&str> = payload .validation_failures() .into_iter() @@ -751,92 +674,45 @@ mod tests { vec![ "missing_account_id", "missing_source", - "missing_order_trade_order_id", - "missing_order_request_event_id", - "missing_order_listing_address", - "missing_order_buyer_pubkey", - "missing_order_seller_pubkey", - "missing_order_decline_reason", + "invalid_trade_mutation_envelope", ] ); } #[test] - fn cancellation_publish_payload_reports_stable_validation_reason_codes() { + fn cancellation_publish_payload_round_trips_semantic_envelope() { let order_id = OrderId::generate(); let farm_id = FarmId::generate(); + let proposal = proposal_envelope(); + let cancellation_envelope = cancellation_envelope(&proposal); let cancellation = - AppPublishPayload::OrderCancellation(AppOrderCancellationPublishPayload { - context: AppPublishContext::new("", ""), - app_order_id: order_id, + AppPublishPayload::TradeCancellation(AppTradeCancellationPublishPayload { + context: AppPublishContext::new("acct_buyer", "trade_cancellation"), + order_id, farm_id, - trade_order_id: " ".to_owned(), - request_event_id: String::new(), - listing_addr: String::new(), - buyer_pubkey: String::new(), - seller_pubkey: String::new(), - reason: " ".to_owned(), - confirm_public_note: false, + envelope: cancellation_envelope.clone(), }); assert_eq!( cancellation.work_kind().sdk_operation(), - TRADE_CANCELLATION_OPERATION_KIND + TRADE_CANCEL_OPERATION_KIND ); + assert_eq!(cancellation.validation_failures(), Vec::new()); - let cancellation_reason_codes: Vec<&str> = cancellation - .validation_failures() - .into_iter() - .map(AppPublishValidationFailure::storage_key) - .collect(); - - assert_eq!( - cancellation_reason_codes, - vec![ - "missing_account_id", - "missing_source", - "missing_order_trade_order_id", - "missing_order_request_event_id", - "missing_order_listing_address", - "missing_order_buyer_pubkey", - "missing_order_seller_pubkey", - "missing_order_cancellation_reason", - ] - ); - - let operation = PendingSyncOperation::from_publish_payload( - AppPublishPayload::OrderCancellation(AppOrderCancellationPublishPayload { - context: AppPublishContext::new("acct_local", "buyer_order_cancellation"), - app_order_id: order_id, - farm_id, - trade_order_id: "order-1".to_owned(), - request_event_id: "request-event-1".to_owned(), - listing_addr: "30402:seller:listing".to_owned(), - buyer_pubkey: "buyer".to_owned(), - seller_pubkey: "seller".to_owned(), - reason: "buyer cancelled order".to_owned(), - confirm_public_note: false, - }), - "2026-04-20T18:00:00Z", - ) - .expect("typed lifecycle payload should serialize"); + let operation = + PendingSyncOperation::from_publish_payload(cancellation, "2026-04-20T18:00:00Z") + .expect("typed lifecycle payload should serialize"); assert_eq!(operation.aggregate, SyncAggregateRef::Order(order_id)); assert_eq!(operation.operation_key, format!("order:{order_id}:upsert")); assert_eq!(operation.operation, SyncOperationKind::Upsert); assert_eq!( operation.publish_payload().expect("payload should parse"), - AppPublishPayload::OrderCancellation(AppOrderCancellationPublishPayload { - context: AppPublishContext::new("acct_local", "buyer_order_cancellation"), - app_order_id: order_id, + AppPublishPayload::TradeCancellation(AppTradeCancellationPublishPayload { + context: AppPublishContext::new("acct_buyer", "trade_cancellation"), + order_id, farm_id, - trade_order_id: "order-1".to_owned(), - request_event_id: "request-event-1".to_owned(), - listing_addr: "30402:seller:listing".to_owned(), - buyer_pubkey: "buyer".to_owned(), - seller_pubkey: "seller".to_owned(), - reason: "buyer cancelled order".to_owned(), - confirm_public_note: false, + envelope: cancellation_envelope, }) ); } @@ -858,4 +734,205 @@ mod tests { assert!(!pending_operation.is_retry()); assert!(pending_operation.publish_payload().is_err()); } + + fn hex_64(character: char) -> String { + core::iter::repeat_n(character, 64).collect() + } + + fn hex_32(character: char) -> String { + core::iter::repeat_n(character, 32).collect() + } + + fn pubkey(character: char) -> RadrootsPublicKey { + RadrootsPublicKey::parse(hex_64(character)).expect("pubkey") + } + + fn event_id(character: char) -> RadrootsEventId { + RadrootsEventId::parse(hex_64(character)).expect("event id") + } + + fn trade_id() -> RadrootsTradeId { + RadrootsTradeId::parse(hex_32('1')).expect("trade id") + } + + fn candidate() -> RadrootsTradeCandidateTermsV1 { + RadrootsTradeCandidateTermsV1 { + candidate_id: None, + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + base_candidate_id: None, + supersession_intent: None, + buyer_pubkey: pubkey('a'), + seller_pubkey: pubkey('b'), + farm_id: RadrootsDTag::parse("farm-1").expect("farm d tag"), + lines: vec![RadrootsTradeCandidateLineV1 { + line_id: RadrootsDTag::parse("line-1").expect("line d tag"), + listing_addr: RadrootsAddressableCoordinate::parse(format!( + "30402:{}:listing-1", + hex_64('b') + )) + .expect("listing addr"), + listing_event_id: event_id('c'), + listing_snapshot_sha256: hex_64('d'), + product_id: "carrots".to_owned(), + option_id: None, + bin_id: RadrootsInventoryBinId::parse("bin-1").expect("inventory bin"), + quantity_mantissa: "2".to_owned(), + quantity_scale: 0, + unit_code: "count".to_owned(), + unit_profile: "mvp-count".to_owned(), + unit_price_mantissa: "500".to_owned(), + currency_code: "USD".to_owned(), + line_subtotal_mantissa: "1000".to_owned(), + replaces_line_id: None, + }], + line_tombstones: Vec::new(), + economics: RadrootsTradeEconomicsProfileV1 { + profile_id: "mvp-fixed".to_owned(), + currency_code: "USD".to_owned(), + currency_exponent: 2, + rounding_profile: "half-even".to_owned(), + subtotal_mantissa: "1000".to_owned(), + discount_total_mantissa: "0".to_owned(), + adjustment_total_mantissa: "0".to_owned(), + total_mantissa: "1000".to_owned(), + adjustments: Vec::new(), + }, + fulfillment: RadrootsFulfillmentProfileV1 { + profile_id: "market-pickup".to_owned(), + method: "pickup".to_owned(), + starts_at_unix_s: 1_800_000_000, + ends_at_unix_s: 1_800_003_600, + timezone: "America/New_York".to_owned(), + utc_offset_seconds: -18_000, + fold: 0, + location_class: "farmstand".to_owned(), + requires_private_terms: false, + }, + cancellation: RadrootsTradeCancellationProfileV1 { + profile_id: "buyer-pre-agreement".to_owned(), + buyer_pre_agreement: true, + post_agreement_cutoff_unix_s: None, + }, + private_terms: None, + proposal_expires_at_unix_s: 1_799_999_000, + } + } + + fn proposal_envelope() -> RadrootsTradeMutationEnvelopeV1 { + canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_owned(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: trade_id(), + root_mutation_id: None, + buyer_pubkey: pubkey('a'), + seller_pubkey: pubkey('b'), + farm_id: RadrootsDTag::parse("farm-1").expect("farm d tag"), + parent_mutation_ids: Vec::new(), + author_pubkey: pubkey('a'), + counterparty_pubkey: pubkey('b'), + authored_at_unix_s: 1_799_000_000, + body: RadrootsTradeMutationBodyV1::Proposal { + candidate: candidate(), + }, + }) + .expect("canonical proposal") + .envelope + } + + fn revision_proposal_envelope( + root: &RadrootsTradeMutationEnvelopeV1, + ) -> RadrootsTradeMutationEnvelopeV1 { + canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID.to_owned(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: root.trade_id.clone(), + root_mutation_id: root.mutation_id.clone(), + buyer_pubkey: pubkey('a'), + seller_pubkey: pubkey('b'), + farm_id: RadrootsDTag::parse("farm-1").expect("farm d tag"), + parent_mutation_ids: vec![root.mutation_id.clone().expect("root mutation id")], + author_pubkey: pubkey('a'), + counterparty_pubkey: pubkey('b'), + authored_at_unix_s: 1_799_000_100, + body: RadrootsTradeMutationBodyV1::RevisionProposal { + candidate: candidate(), + }, + }) + .expect("canonical revision") + .envelope + } + + fn decision_envelope( + proposal: &RadrootsTradeMutationEnvelopeV1, + ) -> RadrootsTradeMutationEnvelopeV1 { + let proposal_mutation_id = proposal.mutation_id.clone().expect("proposal mutation id"); + let candidate_id = match &proposal.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } + | RadrootsTradeMutationBodyV1::RevisionProposal { candidate } => { + candidate.candidate_id.clone().expect("candidate id") + } + _ => unreachable!(), + }; + canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: RADROOTS_TRADE_DECISION_CONTRACT_ID.to_owned(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: proposal.trade_id.clone(), + root_mutation_id: proposal + .root_mutation_id + .clone() + .or(proposal.mutation_id.clone()), + buyer_pubkey: pubkey('a'), + seller_pubkey: pubkey('b'), + farm_id: RadrootsDTag::parse("farm-1").expect("farm d tag"), + parent_mutation_ids: vec![proposal_mutation_id.clone()], + author_pubkey: pubkey('b'), + counterparty_pubkey: pubkey('a'), + authored_at_unix_s: 1_799_000_200, + body: RadrootsTradeMutationBodyV1::Decision { + proposal_mutation_id, + candidate_id, + decision: RadrootsTradeDecisionV1::Declined { + reason: "not available".to_owned(), + }, + }, + }) + .expect("canonical decision") + .envelope + } + + fn cancellation_envelope( + root: &RadrootsTradeMutationEnvelopeV1, + ) -> RadrootsTradeMutationEnvelopeV1 { + let root_mutation_id = root.mutation_id.clone().expect("root mutation id"); + let candidate_id = match &root.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => { + candidate.candidate_id.clone().expect("candidate id") + } + _ => unreachable!(), + }; + canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: RADROOTS_TRADE_CANCELLATION_CONTRACT_ID.to_owned(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: root.trade_id.clone(), + root_mutation_id: Some(root_mutation_id.clone()), + buyer_pubkey: pubkey('a'), + seller_pubkey: pubkey('b'), + farm_id: RadrootsDTag::parse("farm-1").expect("farm d tag"), + parent_mutation_ids: vec![root_mutation_id], + author_pubkey: pubkey('a'), + counterparty_pubkey: pubkey('b'), + authored_at_unix_s: 1_799_000_300, + body: RadrootsTradeMutationBodyV1::Cancellation { + target_candidate_id: Some(candidate_id), + target_claim_mutation_id: None, + reason: "buyer cancelled".to_owned(), + }, + }) + .expect("canonical cancellation") + .envelope + } } diff --git a/crates/view/src/lib.rs b/crates/view/src/lib.rs @@ -8,7 +8,11 @@ use radroots_trade::validation_receipt::{ RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult, RadrootsValidationReceiptType, }; -use radroots_trade::{order::RadrootsOrderProjection, workflow::RadrootsTradeWorkflowState}; +use radroots_trade::workflow::{ + RadrootsTradeAgreementStateV1, RadrootsTradeAttestationStateV1, RadrootsTradeConflictStateV1, + RadrootsTradeEvidenceStateV1, RadrootsTradeNegotiationStateV1, + RadrootsTradePrivateTermsStateV1, RadrootsTradeProjectionV1, +}; use serde::{Deserialize, Serialize}; use std::{collections::BTreeSet, error::Error, fmt, str::FromStr}; use url::Url; @@ -1076,11 +1080,10 @@ pub struct BuyerOrderReviewProjection { pub enum TradeAgreementStatus { #[default] Requested, - AgreedPendingValidation, Committed, + Contested, Declined, Cancelled, - ValidationExpired, Invalid, } @@ -1088,11 +1091,10 @@ impl TradeAgreementStatus { pub const fn storage_key(self) -> &'static str { match self { Self::Requested => "requested", - Self::AgreedPendingValidation => "agreed_pending_validation", Self::Committed => "committed", + Self::Contested => "contested", Self::Declined => "declined", Self::Cancelled => "cancelled", - Self::ValidationExpired => "validation_expired", Self::Invalid => "invalid", } } @@ -1100,34 +1102,32 @@ impl TradeAgreementStatus { pub const fn label_key_id(self) -> &'static str { match self { Self::Requested => "messages.trade.workflow.agreement.requested", - Self::AgreedPendingValidation => { - "messages.trade.workflow.agreement.agreed_pending_validation" - } Self::Committed => "messages.trade.workflow.agreement.committed", + Self::Contested => "messages.trade.workflow.agreement.contested", Self::Declined => "messages.trade.workflow.agreement.declined", Self::Cancelled => "messages.trade.workflow.agreement.cancelled", - Self::ValidationExpired => "messages.trade.workflow.agreement.validation_expired", Self::Invalid => "messages.trade.workflow.agreement.invalid", } } - pub const fn from_active_order_status(status: &RadrootsTradeWorkflowState) -> Self { - match status { - RadrootsTradeWorkflowState::Missing => Self::Invalid, - RadrootsTradeWorkflowState::Requested => Self::Requested, - RadrootsTradeWorkflowState::AgreedPendingValidation => Self::AgreedPendingValidation, - RadrootsTradeWorkflowState::Committed => Self::Committed, - RadrootsTradeWorkflowState::Declined => Self::Declined, - RadrootsTradeWorkflowState::Cancelled => Self::Cancelled, - RadrootsTradeWorkflowState::ValidationExpired => Self::ValidationExpired, - RadrootsTradeWorkflowState::Invalid => Self::Invalid, + pub const fn from_trade_projection(projection: &RadrootsTradeProjectionV1) -> Self { + match projection.agreement_state { + RadrootsTradeAgreementStateV1::Agreed => Self::Committed, + RadrootsTradeAgreementStateV1::Contested => Self::Contested, + RadrootsTradeAgreementStateV1::Cancelled => Self::Cancelled, + RadrootsTradeAgreementStateV1::None => match projection.negotiation_state { + RadrootsTradeNegotiationStateV1::Open => Self::Requested, + RadrootsTradeNegotiationStateV1::ClosedDeclined => Self::Declined, + RadrootsTradeNegotiationStateV1::ClosedExpired => Self::Invalid, + RadrootsTradeNegotiationStateV1::None => Self::Invalid, + }, } } } -impl From<&RadrootsTradeWorkflowState> for TradeAgreementStatus { - fn from(status: &RadrootsTradeWorkflowState) -> Self { - Self::from_active_order_status(status) +impl From<&RadrootsTradeProjectionV1> for TradeAgreementStatus { + fn from(projection: &RadrootsTradeProjectionV1) -> Self { + Self::from_trade_projection(projection) } } @@ -1221,17 +1221,214 @@ impl TradeInventoryStatus { } } - pub fn from_active_order_projection(projection: &RadrootsOrderProjection) -> Self { - match projection.status { - RadrootsTradeWorkflowState::Requested => Self::NeedsReview, - RadrootsTradeWorkflowState::AgreedPendingValidation - | RadrootsTradeWorkflowState::Committed => Self::Reserved, - RadrootsTradeWorkflowState::Declined | RadrootsTradeWorkflowState::Cancelled => { - Self::Available + pub const fn from_trade_projection(projection: &RadrootsTradeProjectionV1) -> Self { + match projection.agreement_state { + RadrootsTradeAgreementStateV1::Agreed => match ( + projection.evidence_state, + projection.conflict_state, + projection.private_terms_state, + ) { + ( + RadrootsTradeEvidenceStateV1::Complete, + RadrootsTradeConflictStateV1::None, + RadrootsTradePrivateTermsStateV1::NotRequired + | RadrootsTradePrivateTermsStateV1::AvailableVerified, + ) => Self::Reserved, + _ => Self::NeedsReview, + }, + RadrootsTradeAgreementStateV1::Cancelled => Self::Available, + RadrootsTradeAgreementStateV1::None => match projection.negotiation_state { + RadrootsTradeNegotiationStateV1::ClosedDeclined + | RadrootsTradeNegotiationStateV1::ClosedExpired => Self::Available, + RadrootsTradeNegotiationStateV1::None | RadrootsTradeNegotiationStateV1::Open => { + Self::NeedsReview + } + }, + RadrootsTradeAgreementStateV1::Contested => Self::NeedsReview, + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TradeEvidenceStatus { + Complete, + #[default] + Missing, + QueryPartial, + UnsupportedVersion, +} + +impl TradeEvidenceStatus { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Complete => "complete", + Self::Missing => "missing", + Self::QueryPartial => "query_partial", + Self::UnsupportedVersion => "unsupported_version", + } + } + + pub const fn label_key_id(self) -> &'static str { + match self { + Self::Complete => "messages.trade.workflow.evidence.complete", + Self::Missing => "messages.trade.workflow.evidence.missing", + Self::QueryPartial => "messages.trade.workflow.evidence.query_partial", + Self::UnsupportedVersion => "messages.trade.workflow.evidence.unsupported_version", + } + } +} + +impl From<RadrootsTradeEvidenceStateV1> for TradeEvidenceStatus { + fn from(value: RadrootsTradeEvidenceStateV1) -> Self { + match value { + RadrootsTradeEvidenceStateV1::Complete => Self::Complete, + RadrootsTradeEvidenceStateV1::Missing => Self::Missing, + RadrootsTradeEvidenceStateV1::QueryPartial => Self::QueryPartial, + RadrootsTradeEvidenceStateV1::UnsupportedVersion => Self::UnsupportedVersion, + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TradeConflictStatus { + #[default] + None, + ConcurrentCandidates, + DoubleAcceptance, + DecisionConflict, + CancellationConflict, + InvalidCausalChain, + InventoryAuthorityConflict, +} + +impl TradeConflictStatus { + pub const fn storage_key(self) -> &'static str { + match self { + Self::None => "none", + Self::ConcurrentCandidates => "concurrent_candidates", + Self::DoubleAcceptance => "double_acceptance", + Self::DecisionConflict => "decision_conflict", + Self::CancellationConflict => "cancellation_conflict", + Self::InvalidCausalChain => "invalid_causal_chain", + Self::InventoryAuthorityConflict => "inventory_authority_conflict", + } + } + + pub const fn label_key_id(self) -> &'static str { + match self { + Self::None => "messages.trade.workflow.conflict.none", + Self::ConcurrentCandidates => "messages.trade.workflow.conflict.concurrent_candidates", + Self::DoubleAcceptance => "messages.trade.workflow.conflict.double_acceptance", + Self::DecisionConflict => "messages.trade.workflow.conflict.decision_conflict", + Self::CancellationConflict => "messages.trade.workflow.conflict.cancellation_conflict", + Self::InvalidCausalChain => "messages.trade.workflow.conflict.invalid_causal_chain", + Self::InventoryAuthorityConflict => { + "messages.trade.workflow.conflict.inventory_authority_conflict" } - RadrootsTradeWorkflowState::Missing - | RadrootsTradeWorkflowState::ValidationExpired - | RadrootsTradeWorkflowState::Invalid => Self::NeedsReview, + } + } +} + +impl From<RadrootsTradeConflictStateV1> for TradeConflictStatus { + fn from(value: RadrootsTradeConflictStateV1) -> Self { + match value { + RadrootsTradeConflictStateV1::None => Self::None, + RadrootsTradeConflictStateV1::ConcurrentCandidates => Self::ConcurrentCandidates, + RadrootsTradeConflictStateV1::DoubleAcceptance => Self::DoubleAcceptance, + RadrootsTradeConflictStateV1::DecisionConflict => Self::DecisionConflict, + RadrootsTradeConflictStateV1::CancellationConflict => Self::CancellationConflict, + RadrootsTradeConflictStateV1::InvalidCausalChain => Self::InvalidCausalChain, + RadrootsTradeConflictStateV1::InventoryAuthorityConflict => { + Self::InventoryAuthorityConflict + } + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TradePrivateTermsStatus { + #[default] + NotRequired, + AvailableVerified, + Missing, + Undecryptable, + CommitmentMismatch, +} + +impl TradePrivateTermsStatus { + pub const fn storage_key(self) -> &'static str { + match self { + Self::NotRequired => "not_required", + Self::AvailableVerified => "available_verified", + Self::Missing => "missing", + Self::Undecryptable => "undecryptable", + Self::CommitmentMismatch => "commitment_mismatch", + } + } + + pub const fn label_key_id(self) -> &'static str { + match self { + Self::NotRequired => "messages.trade.workflow.private_terms.not_required", + Self::AvailableVerified => "messages.trade.workflow.private_terms.available_verified", + Self::Missing => "messages.trade.workflow.private_terms.missing", + Self::Undecryptable => "messages.trade.workflow.private_terms.undecryptable", + Self::CommitmentMismatch => "messages.trade.workflow.private_terms.commitment_mismatch", + } + } +} + +impl From<RadrootsTradePrivateTermsStateV1> for TradePrivateTermsStatus { + fn from(value: RadrootsTradePrivateTermsStateV1) -> Self { + match value { + RadrootsTradePrivateTermsStateV1::NotRequired => Self::NotRequired, + RadrootsTradePrivateTermsStateV1::AvailableVerified => Self::AvailableVerified, + RadrootsTradePrivateTermsStateV1::Missing => Self::Missing, + RadrootsTradePrivateTermsStateV1::Undecryptable => Self::Undecryptable, + RadrootsTradePrivateTermsStateV1::CommitmentMismatch => Self::CommitmentMismatch, + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TradeAttestationStatus { + #[default] + None, + PresentValid, + PresentInvalid, + Conflicting, +} + +impl TradeAttestationStatus { + pub const fn storage_key(self) -> &'static str { + match self { + Self::None => "none", + Self::PresentValid => "present_valid", + Self::PresentInvalid => "present_invalid", + Self::Conflicting => "conflicting", + } + } + + pub const fn label_key_id(self) -> &'static str { + match self { + Self::None => "messages.trade.workflow.attestation.none", + Self::PresentValid => "messages.trade.workflow.attestation.present_valid", + Self::PresentInvalid => "messages.trade.workflow.attestation.present_invalid", + Self::Conflicting => "messages.trade.workflow.attestation.conflicting", + } + } +} + +impl From<RadrootsTradeAttestationStateV1> for TradeAttestationStatus { + fn from(value: RadrootsTradeAttestationStateV1) -> Self { + match value { + RadrootsTradeAttestationStateV1::None => Self::None, + RadrootsTradeAttestationStateV1::PresentValid => Self::PresentValid, + RadrootsTradeAttestationStateV1::PresentInvalid => Self::PresentInvalid, + RadrootsTradeAttestationStateV1::Conflicting => Self::Conflicting, } } } @@ -1472,6 +1669,11 @@ pub struct TradeWorkflowProjection { pub revision: TradeRevisionStatus, pub economics: TradeEconomicsProjection, pub inventory: TradeInventoryStatus, + pub evidence: TradeEvidenceStatus, + pub conflict: TradeConflictStatus, + pub private_terms: TradePrivateTermsStatus, + pub attestation: TradeAttestationStatus, + pub projection_digest: Option<String>, pub provenance: TradeProvenanceProjection, } @@ -1483,29 +1685,36 @@ impl TradeWorkflowProjection { revision: TradeRevisionStatus::None, economics: TradeEconomicsProjection::default(), inventory: TradeInventoryStatus::NeedsReview, + evidence: TradeEvidenceStatus::Missing, + conflict: TradeConflictStatus::None, + private_terms: TradePrivateTermsStatus::NotRequired, + attestation: TradeAttestationStatus::None, + projection_digest: None, provenance: TradeProvenanceProjection::default(), } } - pub fn from_active_order_projection( + pub fn from_trade_projection( order_id: OrderId, - projection: &RadrootsOrderProjection, + projection: &RadrootsTradeProjectionV1, + economics: TradeEconomicsProjection, revision: TradeRevisionStatus, provenance: TradeProvenanceProjection, ) -> Self { let mut workflow = Self::new( order_id, - TradeAgreementStatus::from_active_order_status(&projection.status), + TradeAgreementStatus::from_trade_projection(projection), ); workflow.revision = revision; - workflow.economics = projection - .economics - .as_ref() - .map(TradeEconomicsProjection::from_trade_order_economics) - .unwrap_or_default(); - workflow.inventory = TradeInventoryStatus::from_active_order_projection(projection); - workflow.provenance = provenance - .with_last_event_id(projection.last_event_id.as_ref().map(ToString::to_string)); + workflow.economics = economics; + workflow.inventory = TradeInventoryStatus::from_trade_projection(projection); + workflow.evidence = projection.evidence_state.into(); + workflow.conflict = projection.conflict_state.into(); + workflow.private_terms = projection.private_terms_state.into(); + workflow.attestation = projection.attestation_state.into(); + workflow.projection_digest = (!projection.projection_digest.is_empty()) + .then(|| projection.projection_digest.clone()); + workflow.provenance = provenance; workflow } @@ -1580,19 +1789,19 @@ impl TradeWorkflowProjection { } } -pub fn order_status_from_active_order_projection( - projection: &RadrootsOrderProjection, +pub fn order_status_from_trade_projection( + projection: &RadrootsTradeProjectionV1, ) -> Option<OrderStatus> { - match projection.status { - RadrootsTradeWorkflowState::Missing => None, - RadrootsTradeWorkflowState::Requested - | RadrootsTradeWorkflowState::AgreedPendingValidation - | RadrootsTradeWorkflowState::ValidationExpired => Some(OrderStatus::NeedsAction), - RadrootsTradeWorkflowState::Committed => Some(OrderStatus::Scheduled), - RadrootsTradeWorkflowState::Declined | RadrootsTradeWorkflowState::Cancelled => { - Some(OrderStatus::Declined) - } - RadrootsTradeWorkflowState::Invalid => Some(OrderStatus::NeedsAction), + match projection.agreement_state { + RadrootsTradeAgreementStateV1::Agreed => Some(OrderStatus::Scheduled), + RadrootsTradeAgreementStateV1::Contested => Some(OrderStatus::NeedsAction), + RadrootsTradeAgreementStateV1::Cancelled => Some(OrderStatus::Declined), + RadrootsTradeAgreementStateV1::None => match projection.negotiation_state { + RadrootsTradeNegotiationStateV1::None => None, + RadrootsTradeNegotiationStateV1::Open => Some(OrderStatus::NeedsAction), + RadrootsTradeNegotiationStateV1::ClosedDeclined => Some(OrderStatus::Declined), + RadrootsTradeNegotiationStateV1::ClosedExpired => Some(OrderStatus::NeedsAction), + }, } } @@ -2128,8 +2337,7 @@ mod tests { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, }; use radroots_event::ids::{ - RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, - RadrootsOrderQuoteId, RadrootsPublicKey, + RadrootsInventoryBinId, RadrootsOrderQuoteId, RadrootsPublicKey, RadrootsTradeId, }; use radroots_event::order::{ RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderPricingBasis, @@ -2138,7 +2346,13 @@ mod tests { RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult, RadrootsValidationReceiptType, }; - use radroots_trade::{order::RadrootsOrderProjection, workflow::RadrootsTradeWorkflowState}; + use radroots_trade::workflow::{ + RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION, + RadrootsTradeAgreementStateV1, RadrootsTradeAttestationStateV1, + RadrootsTradeConflictStateV1, RadrootsTradeEvidenceStateV1, + RadrootsTradeFulfillmentStateV1, RadrootsTradeNegotiationStateV1, + RadrootsTradePaymentStateV1, RadrootsTradePrivateTermsStateV1, RadrootsTradeProjectionV1, + }; use super::{ AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, @@ -2173,11 +2387,12 @@ mod tests { RepeatDemandHandoffProjection, SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, StartupSignerEntryProjection, StartupSignerSource, StartupSignerSourceKind, TodayAgendaProjection, TodaySetupTask, - TodaySetupTaskKind, TodaySummary, TradeAgreementStatus, TradeEconomicsProjection, - TradeInventoryStatus, TradeProvenanceProjection, TradeRevisionStatus, + TodaySetupTaskKind, TodaySummary, TradeAgreementStatus, TradeAttestationStatus, + TradeConflictStatus, TradeEconomicsProjection, TradeEvidenceStatus, TradeInventoryStatus, + TradePrivateTermsStatus, TradeProvenanceProjection, TradeRevisionStatus, TradeValidationReceiptProofSystem, TradeValidationReceiptResult, TradeValidationReceiptType, TradeWorkflowProjection, TradeWorkflowSource, - order_status_from_active_order_projection, + order_status_from_trade_projection, }; use std::{collections::BTreeSet, str::FromStr}; use uuid::Uuid; @@ -2798,10 +3013,6 @@ mod tests { RadrootsCoreMoney::new(test_decimal(raw), RadrootsCoreCurrency::USD) } - fn test_order_id(raw: &str) -> RadrootsOrderId { - raw.parse().expect("test order id should parse") - } - fn test_quote_id(raw: &str) -> RadrootsOrderQuoteId { raw.parse().expect("test quote id should parse") } @@ -2810,16 +3021,12 @@ mod tests { raw.parse().expect("test bin id should parse") } - fn test_event_id(raw: &str) -> RadrootsEventId { - raw.parse().expect("test event id should parse") - } - fn test_pubkey(raw: &str) -> RadrootsPublicKey { raw.parse().expect("test pubkey should parse") } - fn test_listing_addr(raw: &str) -> RadrootsListingAddress { - raw.parse().expect("test listing address should parse") + fn test_trade_id(raw: &str) -> RadrootsTradeId { + raw.parse().expect("test trade id should parse") } fn test_trade_economics() -> RadrootsOrderEconomics { @@ -2846,68 +3053,51 @@ mod tests { } } - fn test_active_order_projection(status: RadrootsTradeWorkflowState) -> RadrootsOrderProjection { - RadrootsOrderProjection { - order_id: test_order_id("ord_AAAAAAAAAAAAAAAAAAAAAg"), - status, - request_event_id: Some(test_event_id( - "1111111111111111111111111111111111111111111111111111111111111111", - )), - decision_event_id: Some(test_event_id( - "2222222222222222222222222222222222222222222222222222222222222222", - )), - validation_receipt_event_id: None, - cancellation_event_id: None, - lifecycle_terminal: false, - economics: Some(test_trade_economics()), - agreement_event_id: Some(test_event_id( - "2222222222222222222222222222222222222222222222222222222222222222", - )), - pending_revision_event_id: None, - pending_inventory_reservations: Vec::new(), - committed_inventory_reservations: Vec::new(), - listing_addr: Some(test_listing_addr( - "30402:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:AAAAAAAAAAAAAAAAAAAAAg", - )), + fn test_trade_projection( + agreement_state: RadrootsTradeAgreementStateV1, + negotiation_state: RadrootsTradeNegotiationStateV1, + evidence_state: RadrootsTradeEvidenceStateV1, + conflict_state: RadrootsTradeConflictStateV1, + private_terms_state: RadrootsTradePrivateTermsStateV1, + attestation_state: RadrootsTradeAttestationStateV1, + ) -> RadrootsTradeProjectionV1 { + RadrootsTradeProjectionV1 { + reducer_contract_id: RADROOTS_TRADE_REDUCER_CONTRACT_ID.to_owned(), + reducer_version: RADROOTS_TRADE_REDUCER_VERSION, + trade_id: test_trade_id("11111111111111111111111111111111"), + root_mutation_id: None, buyer_pubkey: Some(test_pubkey( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", )), seller_pubkey: Some(test_pubkey( "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", )), - last_event_id: Some(test_event_id( - "3333333333333333333333333333333333333333333333333333333333333333", - )), + farm_id: None, + negotiation_state, + agreement_state, + evidence_state, + conflict_state, + private_terms_state, + attestation_state, + fulfillment_state: RadrootsTradeFulfillmentStateV1::NotStarted, + payment_state: RadrootsTradePaymentStateV1::NotTracked, + candidate_heads: Vec::new(), + agreement_claims: Vec::new(), + active_agreement_claim_ids: Vec::new(), + contested_claim_ids: Vec::new(), + cancelled_claim_ids: Vec::new(), + declined_candidate_ids: Vec::new(), + missing_parent_ids: Vec::new(), + missing_proposal_ids: Vec::new(), + unsupported_mutation_ids: Vec::new(), issues: Vec::new(), + attestations: Vec::new(), + projection_digest: "projection-digest-v1".to_owned(), } } #[test] - fn trade_workflow_projection_maps_shared_active_order_projection_to_product_axes() { - assert_eq!( - TradeAgreementStatus::from_active_order_status(&RadrootsTradeWorkflowState::Requested), - TradeAgreementStatus::Requested - ); - assert_eq!( - TradeAgreementStatus::from_active_order_status( - &RadrootsTradeWorkflowState::AgreedPendingValidation - ), - TradeAgreementStatus::AgreedPendingValidation - ); - assert_eq!( - TradeAgreementStatus::from_active_order_status(&RadrootsTradeWorkflowState::Committed), - TradeAgreementStatus::Committed - ); - assert_eq!( - TradeAgreementStatus::from_active_order_status( - &RadrootsTradeWorkflowState::ValidationExpired - ), - TradeAgreementStatus::ValidationExpired - ); - assert_eq!( - TradeAgreementStatus::from_active_order_status(&RadrootsTradeWorkflowState::Invalid), - TradeAgreementStatus::Invalid - ); + fn trade_workflow_projection_maps_release_product_reducer_axes() { assert_eq!( TradeRevisionStatus::try_from_storage_key("none"), Ok(TradeRevisionStatus::None) @@ -2936,10 +3126,20 @@ mod tests { ); let order_id = OrderId::generate(); - let active_order = test_active_order_projection(RadrootsTradeWorkflowState::Committed); - let projection = TradeWorkflowProjection::from_active_order_projection( + let economics = + TradeEconomicsProjection::from_trade_order_economics(&test_trade_economics()); + let agreed_projection = test_trade_projection( + RadrootsTradeAgreementStateV1::Agreed, + RadrootsTradeNegotiationStateV1::Open, + RadrootsTradeEvidenceStateV1::Complete, + RadrootsTradeConflictStateV1::None, + RadrootsTradePrivateTermsStateV1::AvailableVerified, + RadrootsTradeAttestationStateV1::PresentValid, + ); + let projection = TradeWorkflowProjection::from_trade_projection( order_id, - &active_order, + &agreed_projection, + economics, TradeRevisionStatus::Updated, TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::RuntimeStore), ); @@ -2949,54 +3149,111 @@ mod tests { assert_eq!(projection.inventory, TradeInventoryStatus::Reserved); assert_eq!(projection.economics.total_minor_units, Some(1234)); assert_eq!(projection.economics.currency_code.as_deref(), Some("USD")); + assert_eq!(projection.evidence, TradeEvidenceStatus::Complete); + assert_eq!(projection.conflict, TradeConflictStatus::None); + assert_eq!( + projection.private_terms, + TradePrivateTermsStatus::AvailableVerified + ); + assert_eq!(projection.attestation, TradeAttestationStatus::PresentValid); + assert_eq!( + projection.projection_digest.as_deref(), + Some("projection-digest-v1") + ); assert_eq!( projection.provenance, TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::RuntimeStore) - .with_last_event_id(Some( - "3333333333333333333333333333333333333333333333333333333333333333".to_owned() - )) ); assert_eq!( - order_status_from_active_order_projection(&active_order), + order_status_from_trade_projection(&agreed_projection), Some(OrderStatus::Scheduled) ); - let pending_validation_order = - test_active_order_projection(RadrootsTradeWorkflowState::AgreedPendingValidation); - let pending_validation_projection = TradeWorkflowProjection::from_active_order_projection( + let contested_projection = test_trade_projection( + RadrootsTradeAgreementStateV1::Contested, + RadrootsTradeNegotiationStateV1::Open, + RadrootsTradeEvidenceStateV1::Complete, + RadrootsTradeConflictStateV1::DecisionConflict, + RadrootsTradePrivateTermsStateV1::AvailableVerified, + RadrootsTradeAttestationStateV1::Conflicting, + ); + let contested_workflow = TradeWorkflowProjection::from_trade_projection( order_id, - &pending_validation_order, + &contested_projection, + TradeEconomicsProjection::default(), TradeRevisionStatus::None, TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::RuntimeStore), ); assert_eq!( - pending_validation_projection.agreement, - TradeAgreementStatus::AgreedPendingValidation + contested_workflow.agreement, + TradeAgreementStatus::Contested ); assert_eq!( - pending_validation_projection.inventory, - TradeInventoryStatus::Reserved + contested_workflow.inventory, + TradeInventoryStatus::NeedsReview + ); + assert_eq!( + contested_workflow.conflict, + TradeConflictStatus::DecisionConflict + ); + assert_eq!( + contested_workflow.attestation, + TradeAttestationStatus::Conflicting ); assert_eq!( - order_status_from_active_order_projection(&pending_validation_order), + order_status_from_trade_projection(&contested_projection), Some(OrderStatus::NeedsAction) ); - let requested_order = test_active_order_projection(RadrootsTradeWorkflowState::Requested); - let requested_projection = TradeWorkflowProjection::from_active_order_projection( + let requested_projection = test_trade_projection( + RadrootsTradeAgreementStateV1::None, + RadrootsTradeNegotiationStateV1::Open, + RadrootsTradeEvidenceStateV1::Missing, + RadrootsTradeConflictStateV1::None, + RadrootsTradePrivateTermsStateV1::Missing, + RadrootsTradeAttestationStateV1::None, + ); + let requested_workflow = TradeWorkflowProjection::from_trade_projection( order_id, - &requested_order, + &requested_projection, + TradeEconomicsProjection::default(), TradeRevisionStatus::None, TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::RuntimeStore), ); assert_eq!( - requested_projection.agreement, + requested_workflow.agreement, TradeAgreementStatus::Requested ); assert_eq!( - requested_projection.inventory, + requested_workflow.inventory, TradeInventoryStatus::NeedsReview ); + assert_eq!(requested_workflow.evidence, TradeEvidenceStatus::Missing); + assert_eq!( + requested_workflow.private_terms, + TradePrivateTermsStatus::Missing + ); + + let declined_projection = test_trade_projection( + RadrootsTradeAgreementStateV1::None, + RadrootsTradeNegotiationStateV1::ClosedDeclined, + RadrootsTradeEvidenceStateV1::Complete, + RadrootsTradeConflictStateV1::None, + RadrootsTradePrivateTermsStateV1::NotRequired, + RadrootsTradeAttestationStateV1::None, + ); + let declined_workflow = TradeWorkflowProjection::from_trade_projection( + order_id, + &declined_projection, + TradeEconomicsProjection::default(), + TradeRevisionStatus::None, + TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::RuntimeStore), + ); + assert_eq!(declined_workflow.agreement, TradeAgreementStatus::Declined); + assert_eq!( + order_status_from_trade_projection(&declined_projection), + Some(OrderStatus::Declined) + ); } #[test] @@ -3054,21 +3311,9 @@ mod tests { #[test] fn trade_workflow_projection_uses_localization_key_ids_for_visible_status_labels() { - assert_eq!( - TradeAgreementStatus::from_active_order_status(&RadrootsTradeWorkflowState::Requested) - .storage_key(), - "requested" - ); assert_eq!(TradeAgreementStatus::Requested.storage_key(), "requested"); - assert_eq!( - TradeAgreementStatus::AgreedPendingValidation.storage_key(), - "agreed_pending_validation" - ); assert_eq!(TradeAgreementStatus::Committed.storage_key(), "committed"); - assert_eq!( - TradeAgreementStatus::ValidationExpired.storage_key(), - "validation_expired" - ); + assert_eq!(TradeAgreementStatus::Contested.storage_key(), "contested"); assert_eq!(TradeAgreementStatus::Invalid.storage_key(), "invalid"); assert_eq!( TradeRevisionStatus::KeptAsPlaced.storage_key(), @@ -3076,6 +3321,22 @@ mod tests { ); assert_eq!(TradeInventoryStatus::Reserved.storage_key(), "reserved"); assert_eq!( + TradeEvidenceStatus::QueryPartial.storage_key(), + "query_partial" + ); + assert_eq!( + TradeConflictStatus::CancellationConflict.storage_key(), + "cancellation_conflict" + ); + assert_eq!( + TradePrivateTermsStatus::CommitmentMismatch.storage_key(), + "commitment_mismatch" + ); + assert_eq!( + TradeAttestationStatus::PresentInvalid.storage_key(), + "present_invalid" + ); + assert_eq!( TradeWorkflowSource::RuntimeStore.storage_key(), "runtime_store" ); @@ -3085,12 +3346,8 @@ mod tests { "messages.trade.workflow.agreement.requested" ); assert_eq!( - TradeAgreementStatus::AgreedPendingValidation.label_key_id(), - "messages.trade.workflow.agreement.agreed_pending_validation" - ); - assert_eq!( - TradeAgreementStatus::ValidationExpired.label_key_id(), - "messages.trade.workflow.agreement.validation_expired" + TradeAgreementStatus::Contested.label_key_id(), + "messages.trade.workflow.agreement.contested" ); assert_eq!( TradeAgreementStatus::Invalid.label_key_id(), @@ -3105,6 +3362,22 @@ mod tests { "messages.trade.workflow.inventory.sold_out" ); assert_eq!( + TradeEvidenceStatus::UnsupportedVersion.label_key_id(), + "messages.trade.workflow.evidence.unsupported_version" + ); + assert_eq!( + TradeConflictStatus::InventoryAuthorityConflict.label_key_id(), + "messages.trade.workflow.conflict.inventory_authority_conflict" + ); + assert_eq!( + TradePrivateTermsStatus::Undecryptable.label_key_id(), + "messages.trade.workflow.private_terms.undecryptable" + ); + assert_eq!( + TradeAttestationStatus::Conflicting.label_key_id(), + "messages.trade.workflow.attestation.conflicting" + ); + assert_eq!( TradeWorkflowSource::Cli.label_key_id(), "messages.trade.workflow.provenance.cli" ); diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -411,13 +411,16 @@ "trade.workflow.axis.agreement": "Agreement", "trade.workflow.axis.revision": "Change", "trade.workflow.axis.inventory": "Stock", + "trade.workflow.axis.evidence": "Evidence", + "trade.workflow.axis.conflict": "Conflict", + "trade.workflow.axis.private_terms": "Private terms", + "trade.workflow.axis.attestation": "Attestation", "trade.workflow.axis.source": "Source", "trade.workflow.agreement.requested": "Requested", - "trade.workflow.agreement.agreed_pending_validation": "Pending validation", "trade.workflow.agreement.committed": "Committed", + "trade.workflow.agreement.contested": "Contested", "trade.workflow.agreement.declined": "Declined", "trade.workflow.agreement.cancelled": "Cancelled", - "trade.workflow.agreement.validation_expired": "Validation expired", "trade.workflow.agreement.invalid": "Invalid", "trade.workflow.revision.none": "No change", "trade.workflow.revision.change_proposed": "Change proposed", @@ -427,6 +430,26 @@ "trade.workflow.inventory.reserved": "Reserved", "trade.workflow.inventory.sold_out": "Sold out", "trade.workflow.inventory.needs_review": "Needs review", + "trade.workflow.evidence.complete": "Complete", + "trade.workflow.evidence.missing": "Missing", + "trade.workflow.evidence.query_partial": "Partial", + "trade.workflow.evidence.unsupported_version": "Unsupported", + "trade.workflow.conflict.none": "None", + "trade.workflow.conflict.concurrent_candidates": "Concurrent candidates", + "trade.workflow.conflict.double_acceptance": "Double acceptance", + "trade.workflow.conflict.decision_conflict": "Decision conflict", + "trade.workflow.conflict.cancellation_conflict": "Cancellation conflict", + "trade.workflow.conflict.invalid_causal_chain": "Invalid causal chain", + "trade.workflow.conflict.inventory_authority_conflict": "Inventory authority conflict", + "trade.workflow.private_terms.not_required": "Not required", + "trade.workflow.private_terms.available_verified": "Verified", + "trade.workflow.private_terms.missing": "Missing", + "trade.workflow.private_terms.undecryptable": "Undecryptable", + "trade.workflow.private_terms.commitment_mismatch": "Commitment mismatch", + "trade.workflow.attestation.none": "None", + "trade.workflow.attestation.present_valid": "Valid", + "trade.workflow.attestation.present_invalid": "Invalid", + "trade.workflow.attestation.conflicting": "Conflicting", "trade.workflow.provenance.app": "App", "trade.workflow.provenance.cli": "CLI", "trade.workflow.provenance.relay": "Relay",