commit 79bd9527a9623219b6bb2ed0ef801559f21149ff
parent 94b45416d2a2a57f4e462ee1145be62819047a4e
Author: triesap <tyson@radroots.org>
Date: Thu, 4 Jun 2026 22:55:12 -0700
app: align lifecycle evidence with reducer
- derive publish preflight evidence through the shared active-order reducer
- reject invalid, forked, terminal, and unchained lifecycle records
- cover seller fulfillment, buyer cancellation, buyer receipt, and revision parents
- add reducer-backed lifecycle regression coverage
Diffstat:
3 files changed, 736 insertions(+), 106 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -5070,6 +5070,7 @@ dependencies = [
"radroots_app_ui",
"radroots_app_view",
"radroots_core",
+ "radroots_events_codec",
"radroots_identity",
"radroots_local_events",
"radroots_nostr",
@@ -5078,6 +5079,7 @@ dependencies = [
"radroots_sdk",
"radroots_secret_vault",
"radroots_sql_core",
+ "radroots_trade",
"serde_json",
"thiserror 2.0.18",
"tokio",
diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml
@@ -14,6 +14,7 @@ gpui.workspace = true
gpui-component.workspace = true
gpui-component-assets.workspace = true
radroots_core.workspace = true
+radroots_events_codec.workspace = true
radroots_identity.workspace = true
radroots_nostr.workspace = true
radroots_nostr_accounts.workspace = true
@@ -30,6 +31,7 @@ radroots_app_sync.workspace = true
radroots_app_ui.workspace = true
radroots_local_events.workspace = true
radroots_sql_core.workspace = true
+radroots_trade.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs
@@ -64,6 +64,7 @@ use radroots_core::{
RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
RadrootsCoreQuantityPrice, RadrootsCoreUnit,
};
+use radroots_events_codec::trade::active_trade_event_context_from_tags;
use radroots_identity::{RadrootsIdentity, RadrootsIdentityId};
use radroots_local_events::{
BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT,
@@ -96,6 +97,13 @@ use radroots_sdk::{
SdkPublishReceipt, SdkTransportMode, SdkTransportReceipt, SignerConfig,
};
use radroots_sql_core::SqliteExecutor;
+use radroots_trade::order::{
+ RadrootsActiveOrderCancellationRecord, RadrootsActiveOrderDecisionRecord,
+ RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderPaymentRecord,
+ RadrootsActiveOrderReceiptRecord, RadrootsActiveOrderRequestRecord,
+ RadrootsActiveOrderRevisionDecisionRecord, RadrootsActiveOrderRevisionProposalRecord,
+ RadrootsActiveOrderSettlementRecord, RadrootsActiveOrderStatus, reduce_active_order_events,
+};
use serde_json::json;
use thiserror::Error;
use tokio::runtime::Builder as TokioRuntimeBuilder;
@@ -180,6 +188,7 @@ pub enum AppSellerOrderDecisionCommand {
#[derive(Clone, Debug, Eq, PartialEq)]
struct ResolvedAppSellerOrderRequest {
request_event_id: String,
+ request_author_pubkey: String,
listing_event_id: Option<String>,
payload: RadrootsTradeOrderRequested,
}
@@ -218,6 +227,17 @@ struct ResolvedAppOrderLifecycleEvidence {
receipt_event_id: Option<String>,
}
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+struct AppActiveOrderEvidenceBuckets {
+ requests: Vec<RadrootsActiveOrderRequestRecord>,
+ decisions: Vec<RadrootsActiveOrderDecisionRecord>,
+ revision_proposals: Vec<RadrootsActiveOrderRevisionProposalRecord>,
+ revision_decisions: Vec<RadrootsActiveOrderRevisionDecisionRecord>,
+ fulfillments: Vec<RadrootsActiveOrderFulfillmentRecord>,
+ cancellations: Vec<RadrootsActiveOrderCancellationRecord>,
+ receipts: Vec<RadrootsActiveOrderReceiptRecord>,
+}
+
#[derive(Debug, Default)]
struct AppDirectRelayIngestReport {
local_import: AppLocalInteropImportReport,
@@ -5370,7 +5390,12 @@ impl DesktopAppRuntimeState {
.then_with(|| left.id.cmp(&right.id))
});
- let mut evidence = ResolvedAppOrderLifecycleEvidence::default();
+ let mut buckets = AppActiveOrderEvidenceBuckets::default();
+ buckets.requests.push(RadrootsActiveOrderRequestRecord {
+ event_id: request.request_event_id.clone(),
+ author_pubkey: request.request_author_pubkey.clone(),
+ payload: request.payload.clone(),
+ });
for event in events {
if trade_chain_tag_value(&event, "e_root").as_deref()
!= Some(request.request_event_id.as_str())
@@ -5379,84 +5404,197 @@ impl DesktopAppRuntimeState {
}
match event.kind {
3423 => {
- let Ok(envelope) = radroots_sdk::trade::parse_order_decision(&event) else {
- continue;
- };
- if !trade_decision_matches_request(&envelope.payload, &request.payload) {
- continue;
- }
- evidence.decision = Some(ResolvedAppOrderDecisionEvidence {
+ let envelope =
+ radroots_sdk::trade::parse_order_decision(&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(RadrootsActiveOrderDecisionRecord {
event_id: event.id,
+ author_pubkey: event.author,
+ counterparty_pubkey: context.0,
+ root_event_id: context.1,
+ prev_event_id: context.2,
payload: envelope.payload,
});
}
3424 => {
let Ok(envelope) = radroots_sdk::trade::parse_order_revision_proposal(&event)
else {
- continue;
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "order lifecycle evidence is invalid",
+ });
};
- if !trade_revision_proposal_matches_request(&envelope.payload, &request.payload)
- {
- continue;
- }
- evidence
+ let context = active_order_event_record_context(&event, envelope.message_type)?;
+ buckets
.revision_proposals
- .push(ResolvedAppOrderRevisionProposalEvidence {
+ .push(RadrootsActiveOrderRevisionProposalRecord {
event_id: event.id,
+ author_pubkey: event.author,
+ counterparty_pubkey: context.0,
+ root_event_id: context.1,
+ prev_event_id: context.2,
payload: envelope.payload,
});
}
3425 => {
let Ok(envelope) = radroots_sdk::trade::parse_order_revision_decision(&event)
else {
- continue;
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "order lifecycle evidence is invalid",
+ });
};
- if !trade_revision_decision_matches_request(&envelope.payload, &request.payload)
- {
- continue;
- }
- evidence
+ let context = active_order_event_record_context(&event, envelope.message_type)?;
+ buckets
.revision_decisions
- .push(ResolvedAppOrderRevisionDecisionEvidence {
+ .push(RadrootsActiveOrderRevisionDecisionRecord {
event_id: event.id,
+ author_pubkey: event.author,
+ counterparty_pubkey: context.0,
+ root_event_id: context.1,
+ prev_event_id: context.2,
payload: envelope.payload,
});
}
3432 => {
let Ok(envelope) = radroots_sdk::trade::parse_order_cancellation(&event) else {
- continue;
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "order lifecycle evidence is invalid",
+ });
};
- if !trade_cancellation_matches_request(&envelope.payload, &request.payload) {
- continue;
- }
- evidence.cancellation_event_id = Some(event.id);
+ let context = active_order_event_record_context(&event, envelope.message_type)?;
+ buckets
+ .cancellations
+ .push(RadrootsActiveOrderCancellationRecord {
+ event_id: event.id,
+ author_pubkey: event.author,
+ counterparty_pubkey: context.0,
+ root_event_id: context.1,
+ prev_event_id: context.2,
+ payload: envelope.payload,
+ });
}
3433 => {
let Ok(envelope) = radroots_sdk::trade::parse_fulfillment_update(&event) else {
- continue;
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "order lifecycle evidence is invalid",
+ });
};
- if !trade_fulfillment_matches_request(&envelope.payload, &request.payload) {
- continue;
- }
- evidence.latest_fulfillment = Some(ResolvedAppOrderFulfillmentEvidence {
- event_id: event.id,
- status: envelope.payload.status,
- });
+ let context = active_order_event_record_context(&event, envelope.message_type)?;
+ buckets
+ .fulfillments
+ .push(RadrootsActiveOrderFulfillmentRecord {
+ event_id: event.id,
+ author_pubkey: event.author,
+ counterparty_pubkey: context.0,
+ root_event_id: context.1,
+ prev_event_id: context.2,
+ payload: envelope.payload,
+ });
}
3434 => {
let Ok(envelope) = radroots_sdk::trade::parse_buyer_receipt(&event) else {
- continue;
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "order lifecycle evidence is invalid",
+ });
};
- if !trade_receipt_matches_request(&envelope.payload, &request.payload) {
- continue;
- }
- evidence.receipt_event_id = Some(event.id);
+ let context = active_order_event_record_context(&event, envelope.message_type)?;
+ buckets.receipts.push(RadrootsActiveOrderReceiptRecord {
+ event_id: event.id,
+ author_pubkey: event.author,
+ counterparty_pubkey: context.0,
+ root_event_id: context.1,
+ prev_event_id: context.2,
+ payload: envelope.payload,
+ });
}
_ => {}
}
}
- Ok(evidence)
+ let projection = reduce_active_order_events(
+ request.payload.order_id.as_str(),
+ buckets.requests.clone(),
+ buckets.decisions.clone(),
+ buckets.revision_proposals.clone(),
+ buckets.revision_decisions.clone(),
+ buckets.fulfillments.clone(),
+ buckets.cancellations.clone(),
+ buckets.receipts.clone(),
+ Vec::<RadrootsActiveOrderPaymentRecord>::new(),
+ Vec::<RadrootsActiveOrderSettlementRecord>::new(),
+ );
+ if !projection.issues.is_empty() || projection.status == RadrootsActiveOrderStatus::Invalid
+ {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "order lifecycle evidence is invalid",
+ });
+ }
+ if projection.request_event_id.as_deref() != Some(request.request_event_id.as_str()) {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "order lifecycle evidence is invalid",
+ });
+ }
+
+ let decision = projection
+ .decision_event_id
+ .as_deref()
+ .map(|event_id| {
+ buckets
+ .decisions
+ .iter()
+ .find(|decision| decision.event_id == event_id)
+ .map(|decision| ResolvedAppOrderDecisionEvidence {
+ event_id: decision.event_id.clone(),
+ payload: decision.payload.clone(),
+ })
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "order lifecycle evidence is invalid",
+ })
+ })
+ .transpose()?;
+ let latest_fulfillment = projection
+ .fulfillment_event_id
+ .as_deref()
+ .map(|event_id| {
+ buckets
+ .fulfillments
+ .iter()
+ .find(|fulfillment| fulfillment.event_id == event_id)
+ .map(|fulfillment| ResolvedAppOrderFulfillmentEvidence {
+ event_id: fulfillment.event_id.clone(),
+ status: fulfillment.payload.status,
+ })
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "order lifecycle evidence is invalid",
+ })
+ })
+ .transpose()?;
+
+ Ok(ResolvedAppOrderLifecycleEvidence {
+ decision,
+ revision_proposals: buckets
+ .revision_proposals
+ .into_iter()
+ .map(|proposal| ResolvedAppOrderRevisionProposalEvidence {
+ event_id: proposal.event_id,
+ payload: proposal.payload,
+ })
+ .collect(),
+ revision_decisions: buckets
+ .revision_decisions
+ .into_iter()
+ .map(|decision| ResolvedAppOrderRevisionDecisionEvidence {
+ event_id: decision.event_id,
+ payload: decision.payload,
+ })
+ .collect(),
+ latest_fulfillment,
+ cancellation_event_id: projection.cancellation_event_id,
+ receipt_event_id: projection.receipt_event_id,
+ })
}
fn collect_order_lifecycle_signed_events(
@@ -9195,64 +9333,27 @@ fn trade_chain_tag_value(event: &radroots_sdk::RadrootsNostrEvent, key: &str) ->
})
}
-fn trade_decision_matches_request(
- decision: &RadrootsTradeOrderDecisionEvent,
- request: &RadrootsTradeOrderRequested,
-) -> bool {
- decision.order_id == request.order_id
- && decision.listing_addr == request.listing_addr
- && decision.buyer_pubkey == request.buyer_pubkey
- && decision.seller_pubkey == request.seller_pubkey
-}
-
-fn trade_revision_proposal_matches_request(
- proposal: &RadrootsTradeOrderRevisionProposed,
- request: &RadrootsTradeOrderRequested,
-) -> bool {
- proposal.order_id == request.order_id
- && proposal.listing_addr == request.listing_addr
- && proposal.buyer_pubkey == request.buyer_pubkey
- && proposal.seller_pubkey == request.seller_pubkey
-}
-
-fn trade_revision_decision_matches_request(
- decision: &RadrootsTradeOrderRevisionDecisionEvent,
- request: &RadrootsTradeOrderRequested,
-) -> bool {
- decision.order_id == request.order_id
- && decision.listing_addr == request.listing_addr
- && decision.buyer_pubkey == request.buyer_pubkey
- && decision.seller_pubkey == request.seller_pubkey
-}
-
-fn trade_fulfillment_matches_request(
- fulfillment: &RadrootsTradeFulfillmentUpdated,
- request: &RadrootsTradeOrderRequested,
-) -> bool {
- fulfillment.order_id == request.order_id
- && fulfillment.listing_addr == request.listing_addr
- && fulfillment.buyer_pubkey == request.buyer_pubkey
- && fulfillment.seller_pubkey == request.seller_pubkey
-}
-
-fn trade_cancellation_matches_request(
- cancellation: &RadrootsTradeOrderCancelled,
- request: &RadrootsTradeOrderRequested,
-) -> bool {
- cancellation.order_id == request.order_id
- && cancellation.listing_addr == request.listing_addr
- && cancellation.buyer_pubkey == request.buyer_pubkey
- && cancellation.seller_pubkey == request.seller_pubkey
-}
-
-fn trade_receipt_matches_request(
- receipt: &RadrootsTradeBuyerReceipt,
- request: &RadrootsTradeOrderRequested,
-) -> bool {
- receipt.order_id == request.order_id
- && receipt.listing_addr == request.listing_addr
- && receipt.buyer_pubkey == request.buyer_pubkey
- && receipt.seller_pubkey == request.seller_pubkey
+fn active_order_event_record_context(
+ event: &radroots_sdk::RadrootsNostrEvent,
+ message_type: radroots_sdk::trade::RadrootsActiveTradeMessageType,
+) -> Result<(String, String, String), AppSqliteError> {
+ let context =
+ active_trade_event_context_from_tags(message_type, &event.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 active_order_revision_parent_event_id(
@@ -9338,6 +9439,7 @@ fn insert_seller_order_request_evidence(
.entry(event.id.clone())
.or_insert_with(|| ResolvedAppSellerOrderRequest {
request_event_id: event.id.clone(),
+ request_author_pubkey: event.author.clone(),
listing_event_id: listing_event_id_from_tags(&event.tags),
payload,
});
@@ -9623,11 +9725,12 @@ mod tests {
};
use radroots_sdk::RadrootsNostrEventPtr;
use radroots_sdk::trade::{
- RadrootsActiveTradeFulfillmentState, RadrootsTradeFulfillmentUpdated,
- RadrootsTradeInventoryCommitment, RadrootsTradeOrderDecision,
- RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomicItem,
- RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested,
- RadrootsTradeOrderRevisionDecision, RadrootsTradePricingBasis,
+ RadrootsActiveTradeFulfillmentState, RadrootsTradeBuyerReceipt,
+ RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment,
+ RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent,
+ RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem,
+ RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision,
+ RadrootsTradeOrderRevisionProposed, RadrootsTradePricingBasis,
};
use radroots_sql_core::{SqlExecutor, SqliteExecutor};
use serde_json::json;
@@ -15039,6 +15142,175 @@ mod tests {
}
#[test]
+ fn runtime_rejects_seller_order_fulfillment_delivered_with_reducer_invalid_ready_evidence() {
+ for label in [
+ "seller_order_fulfillment_delivery_unchained_ready",
+ "seller_order_fulfillment_delivery_forked_ready",
+ ] {
+ let relay = ThreadedAckRelay::spawn();
+ let (runtime, paths, order_id, product_id, seller_pubkey, buyer_pubkey) =
+ seller_order_decision_runtime(label, 6, 2);
+ install_direct_relay_sync_transport(&runtime, &relay);
+ let listing_key = super::d_tag_from_uuid(product_id.as_uuid());
+ let listing_addr = format!("30402:{seller_pubkey}:{listing_key}");
+ let request_event_id = "event-app:signed_event:order-request:seller-order-decision-1";
+ let decision_event_id = append_signed_order_decision_record(
+ &paths,
+ "seller-order-decision-1",
+ request_event_id,
+ listing_addr.as_str(),
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ 2,
+ );
+ if label.ends_with("unchained_ready") {
+ append_signed_order_fulfillment_record_with_status_and_key(
+ &paths,
+ "seller-order-decision-1",
+ "seller-order-decision-1-unchained-ready",
+ request_event_id,
+ request_event_id,
+ listing_addr.as_str(),
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ RadrootsActiveTradeFulfillmentState::ReadyForPickup,
+ );
+ } else {
+ append_signed_order_fulfillment_record_with_status_and_key(
+ &paths,
+ "seller-order-decision-1",
+ "seller-order-decision-1-forked-preparing",
+ request_event_id,
+ decision_event_id.as_str(),
+ listing_addr.as_str(),
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ RadrootsActiveTradeFulfillmentState::Preparing,
+ );
+ append_signed_order_fulfillment_record_with_status_and_key(
+ &paths,
+ "seller-order-decision-1",
+ "seller-order-decision-1-forked-ready",
+ request_event_id,
+ decision_event_id.as_str(),
+ listing_addr.as_str(),
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ RadrootsActiveTradeFulfillmentState::ReadyForPickup,
+ );
+ }
+
+ let error = runtime
+ .publish_order_delivered(order_id)
+ .expect_err("seller delivered fulfillment should reject reducer-invalid evidence");
+
+ assert_order_lifecycle_evidence_invalid(error);
+ assert_eq!(relay.event_count(), 0);
+ cleanup_bootstrapped_runtime_paths(&paths);
+ }
+ }
+
+ #[test]
+ fn runtime_rejects_seller_order_fulfillment_ready_with_invalid_terminal_evidence() {
+ for label in [
+ "seller_order_fulfillment_invalid_cancellation",
+ "seller_order_fulfillment_invalid_receipt",
+ ] {
+ let relay = ThreadedAckRelay::spawn();
+ let (runtime, paths, order_id, product_id, seller_pubkey, buyer_pubkey) =
+ seller_order_decision_runtime(label, 6, 2);
+ install_direct_relay_sync_transport(&runtime, &relay);
+ let listing_key = super::d_tag_from_uuid(product_id.as_uuid());
+ let listing_addr = format!("30402:{seller_pubkey}:{listing_key}");
+ let request_event_id = "event-app:signed_event:order-request:seller-order-decision-1";
+ append_signed_order_decision_record(
+ &paths,
+ "seller-order-decision-1",
+ request_event_id,
+ listing_addr.as_str(),
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ 2,
+ );
+ if label.ends_with("invalid_cancellation") {
+ append_signed_order_cancellation_record_with_prev(
+ &paths,
+ "seller-order-decision-1",
+ "seller-order-decision-1-invalid-cancellation",
+ request_event_id,
+ request_event_id,
+ listing_addr.as_str(),
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ );
+ } else {
+ append_signed_order_receipt_record_with_prev(
+ &paths,
+ "seller-order-decision-1",
+ "seller-order-decision-1-invalid-receipt",
+ request_event_id,
+ request_event_id,
+ listing_addr.as_str(),
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ true,
+ );
+ }
+
+ let error = runtime
+ .publish_order_ready_for_pickup(order_id)
+ .expect_err("seller ready fulfillment should reject invalid terminal evidence");
+
+ assert_order_lifecycle_evidence_invalid(error);
+ assert_eq!(relay.event_count(), 0);
+ cleanup_bootstrapped_runtime_paths(&paths);
+ }
+ }
+
+ #[test]
+ fn runtime_rejects_seller_order_revision_with_reducer_invalid_parent_evidence() {
+ let relay = ThreadedAckRelay::spawn();
+ let (runtime, paths, order_id, product_id, seller_pubkey, buyer_pubkey) =
+ seller_order_decision_runtime("seller_order_revision_invalid_parent", 6, 2);
+ install_direct_relay_sync_transport(&runtime, &relay);
+ let listing_key = super::d_tag_from_uuid(product_id.as_uuid());
+ let listing_addr = format!("30402:{seller_pubkey}:{listing_key}");
+ let request_event_id = "event-app:signed_event:order-request:seller-order-decision-1";
+ append_signed_order_decision_record(
+ &paths,
+ "seller-order-decision-1",
+ request_event_id,
+ listing_addr.as_str(),
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ 2,
+ );
+ append_signed_order_revision_proposal_record_with_prev(
+ &paths,
+ "seller-order-decision-1",
+ "seller-order-decision-1-stale-revision",
+ request_event_id,
+ request_event_id,
+ listing_addr.as_str(),
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ );
+
+ let error = runtime
+ .publish_order_revision_proposal(
+ order_id,
+ revision_test_order_items(),
+ revision_test_order_economics(),
+ "harvest count updated",
+ )
+ .expect_err("seller revision proposal should reject reducer-invalid parent evidence");
+
+ assert_order_lifecycle_evidence_invalid(error);
+ assert_eq!(relay.event_count(), 0);
+ cleanup_bootstrapped_runtime_paths(&paths);
+ }
+
+ #[test]
fn runtime_places_supported_buyer_order_into_shared_local_events() {
let (runtime, paths) = bootstrapped_runtime("buyer_order_local_event");
assert!(
@@ -15756,6 +16028,163 @@ mod tests {
}
#[test]
+ fn runtime_rejects_linked_buyer_cancellation_with_reducer_invalid_evidence() {
+ let relay = ThreadedAckRelay::spawn();
+ let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_cancel_invalid", false);
+ install_direct_relay_sync_transport(&fixture.runtime, &relay);
+ fixture
+ .runtime
+ .refresh_shared_local_events()
+ .expect("linked buyer local events should import");
+ assert!(
+ fixture
+ .runtime
+ .open_personal_order_detail(fixture.order_id)
+ .expect("linked buyer order detail should open")
+ );
+ append_signed_order_cancellation_record_with_prev(
+ &fixture.paths,
+ fixture.trade_order_id.as_str(),
+ "linked-buyer-order-cancel-invalid-a",
+ fixture.request_event_id.as_str(),
+ fixture.decision_event_id.as_str(),
+ fixture.listing_addr.as_str(),
+ fixture.buyer_pubkey.as_str(),
+ fixture.seller_pubkey.as_str(),
+ );
+ append_signed_order_cancellation_record_with_prev(
+ &fixture.paths,
+ fixture.trade_order_id.as_str(),
+ "linked-buyer-order-cancel-invalid-b",
+ fixture.request_event_id.as_str(),
+ fixture.decision_event_id.as_str(),
+ fixture.listing_addr.as_str(),
+ fixture.buyer_pubkey.as_str(),
+ fixture.seller_pubkey.as_str(),
+ );
+
+ let error = fixture
+ .runtime
+ .publish_buyer_order_cancel(fixture.order_id)
+ .expect_err("linked buyer cancellation should reject reducer-invalid evidence");
+
+ assert_order_lifecycle_evidence_invalid(error);
+ assert_eq!(relay.event_count(), 0);
+ cleanup_bootstrapped_runtime_paths(&fixture.paths);
+ }
+
+ #[test]
+ fn runtime_rejects_linked_buyer_receipt_with_reducer_invalid_fulfillment_evidence() {
+ let relay = ThreadedAckRelay::spawn();
+ let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_receipt_invalid", true);
+ install_direct_relay_sync_transport(&fixture.runtime, &relay);
+ fixture
+ .runtime
+ .refresh_shared_local_events()
+ .expect("linked buyer local events should import");
+ assert!(
+ fixture
+ .runtime
+ .open_personal_order_detail(fixture.order_id)
+ .expect("linked buyer order detail should open")
+ );
+ append_signed_order_fulfillment_record_with_status_and_key(
+ &fixture.paths,
+ fixture.trade_order_id.as_str(),
+ "linked-buyer-order-receipt-forked-ready",
+ fixture.request_event_id.as_str(),
+ fixture.decision_event_id.as_str(),
+ fixture.listing_addr.as_str(),
+ fixture.buyer_pubkey.as_str(),
+ fixture.seller_pubkey.as_str(),
+ RadrootsActiveTradeFulfillmentState::ReadyForPickup,
+ );
+ let resolver_error = {
+ let state = fixture.runtime.lock_state();
+ let request = state
+ .resolve_seller_order_request_evidence(fixture.order_id)
+ .expect("linked buyer request evidence should resolve");
+ state
+ .resolve_order_lifecycle_evidence(&request)
+ .expect_err("linked buyer receipt evidence should be reducer-invalid")
+ };
+ assert_order_lifecycle_evidence_invalid(resolver_error);
+
+ let error = fixture
+ .runtime
+ .publish_buyer_order_receipt(fixture.order_id)
+ .expect_err("linked buyer receipt should reject reducer-invalid fulfillment evidence");
+
+ assert!(
+ matches!(error, AppSqliteError::InvalidProjection { .. }),
+ "{error:?}"
+ );
+ assert_eq!(relay.event_count(), 0);
+ cleanup_bootstrapped_runtime_paths(&fixture.paths);
+ }
+
+ #[test]
+ fn runtime_publishes_linked_buyer_revision_decision_from_reducer_valid_parent() {
+ let relay = ThreadedAckRelay::spawn();
+ let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_revision", false);
+ let proposal_event_id = append_signed_order_revision_proposal_record_with_prev(
+ &fixture.paths,
+ fixture.trade_order_id.as_str(),
+ "linked-buyer-order-revision-proposal",
+ fixture.request_event_id.as_str(),
+ fixture.decision_event_id.as_str(),
+ fixture.listing_addr.as_str(),
+ fixture.buyer_pubkey.as_str(),
+ fixture.seller_pubkey.as_str(),
+ );
+ install_direct_relay_sync_transport(&fixture.runtime, &relay);
+ fixture
+ .runtime
+ .refresh_shared_local_events()
+ .expect("linked buyer local events should import");
+ assert!(
+ fixture
+ .runtime
+ .open_personal_order_detail(fixture.order_id)
+ .expect("linked buyer order detail should open")
+ );
+
+ assert!(
+ fixture
+ .runtime
+ .publish_buyer_order_revision_accept(fixture.order_id)
+ .expect("linked buyer revision decision should publish")
+ );
+
+ assert_eq!(relay.event_count(), 1);
+ let revision_decision_events =
+ shared_order_events_by_kind(&fixture.paths, 3425, fixture.buyer_pubkey.as_str());
+ assert_eq!(revision_decision_events.len(), 1);
+ let revision_decision_event = revision_decision_events
+ .first()
+ .expect("linked buyer revision decision event");
+ let revision_decision =
+ radroots_sdk::trade::parse_order_revision_decision(revision_decision_event)
+ .expect("linked buyer revision decision should parse");
+ assert_eq!(
+ revision_decision.payload.decision,
+ RadrootsTradeOrderRevisionDecision::Accepted
+ );
+ assert!(event_has_tag(
+ revision_decision_event,
+ "e_root",
+ fixture.request_event_id.as_str()
+ ));
+ assert!(event_has_tag(
+ revision_decision_event,
+ "e_prev",
+ proposal_event_id.as_str()
+ ));
+
+ cleanup_bootstrapped_runtime_paths(&fixture.paths);
+ }
+
+ #[test]
fn runtime_repeat_personal_order_readds_only_currently_eligible_items() {
let runtime = memory_runtime();
let (account_id, farm_id) = provision_ready_farmer_account(&runtime);
@@ -19077,6 +19506,7 @@ mod tests {
request_event_id: String,
decision_event_id: String,
fulfillment_event_id: Option<String>,
+ listing_addr: String,
buyer_pubkey: String,
seller_pubkey: String,
}
@@ -19173,6 +19603,7 @@ mod tests {
request_event_id,
decision_event_id,
fulfillment_event_id,
+ listing_addr,
buyer_pubkey,
seller_pubkey,
}
@@ -19596,6 +20027,30 @@ mod tests {
seller_pubkey: &str,
status: RadrootsActiveTradeFulfillmentState,
) -> String {
+ append_signed_order_fulfillment_record_with_status_and_key(
+ paths,
+ trade_order_id,
+ trade_order_id,
+ request_event_id,
+ decision_event_id,
+ listing_addr,
+ buyer_pubkey,
+ seller_pubkey,
+ status,
+ )
+ }
+
+ fn append_signed_order_fulfillment_record_with_status_and_key(
+ paths: &AppDesktopRuntimePaths,
+ trade_order_id: &str,
+ event_key: &str,
+ request_event_id: &str,
+ prev_event_id: &str,
+ listing_addr: &str,
+ buyer_pubkey: &str,
+ seller_pubkey: &str,
+ status: RadrootsActiveTradeFulfillmentState,
+ ) -> String {
let payload = RadrootsTradeFulfillmentUpdated {
order_id: trade_order_id.to_owned(),
listing_addr: listing_addr.to_owned(),
@@ -19605,12 +20060,12 @@ mod tests {
};
let parts = radroots_sdk::trade::build_fulfillment_update_draft(
request_event_id,
- decision_event_id,
+ prev_event_id,
&payload,
)
.expect("fulfillment update draft should build")
.into_wire_parts();
- let record_id = format!("app:signed_event:fulfillment:{trade_order_id}");
+ let record_id = format!("app:signed_event:fulfillment:{event_key}");
let event_id = format!("event-{record_id}");
append_trade_signed_event_record(
paths,
@@ -19625,6 +20080,177 @@ mod tests {
event_id
}
+ fn append_signed_order_cancellation_record_with_prev(
+ paths: &AppDesktopRuntimePaths,
+ trade_order_id: &str,
+ event_key: &str,
+ request_event_id: &str,
+ prev_event_id: &str,
+ listing_addr: &str,
+ buyer_pubkey: &str,
+ seller_pubkey: &str,
+ ) -> String {
+ let payload = RadrootsTradeOrderCancelled {
+ order_id: trade_order_id.to_owned(),
+ listing_addr: listing_addr.to_owned(),
+ buyer_pubkey: buyer_pubkey.to_owned(),
+ seller_pubkey: seller_pubkey.to_owned(),
+ reason: "buyer cancelled order".to_owned(),
+ };
+ let parts = radroots_sdk::trade::build_order_cancellation_draft(
+ request_event_id,
+ prev_event_id,
+ &payload,
+ )
+ .expect("order cancellation draft should build")
+ .into_wire_parts();
+ let record_id = format!("app:signed_event:cancellation:{event_key}");
+ let event_id = format!("event-{record_id}");
+ append_trade_signed_event_record(
+ paths,
+ record_id.as_str(),
+ event_id.as_str(),
+ i64::from(parts.kind),
+ buyer_pubkey,
+ listing_addr,
+ json!(parts.tags),
+ parts.content,
+ );
+ event_id
+ }
+
+ fn append_signed_order_receipt_record_with_prev(
+ paths: &AppDesktopRuntimePaths,
+ trade_order_id: &str,
+ event_key: &str,
+ request_event_id: &str,
+ prev_event_id: &str,
+ listing_addr: &str,
+ buyer_pubkey: &str,
+ seller_pubkey: &str,
+ received: bool,
+ ) -> String {
+ let payload = RadrootsTradeBuyerReceipt {
+ order_id: trade_order_id.to_owned(),
+ listing_addr: listing_addr.to_owned(),
+ buyer_pubkey: buyer_pubkey.to_owned(),
+ seller_pubkey: seller_pubkey.to_owned(),
+ received,
+ issue: None,
+ received_at: 1_774_000_030,
+ };
+ let parts = radroots_sdk::trade::build_buyer_receipt_draft(
+ request_event_id,
+ prev_event_id,
+ &payload,
+ )
+ .expect("buyer receipt draft should build")
+ .into_wire_parts();
+ let record_id = format!("app:signed_event:receipt:{event_key}");
+ let event_id = format!("event-{record_id}");
+ append_trade_signed_event_record(
+ paths,
+ record_id.as_str(),
+ event_id.as_str(),
+ i64::from(parts.kind),
+ buyer_pubkey,
+ listing_addr,
+ json!(parts.tags),
+ parts.content,
+ );
+ event_id
+ }
+
+ fn append_signed_order_revision_proposal_record_with_prev(
+ paths: &AppDesktopRuntimePaths,
+ trade_order_id: &str,
+ event_key: &str,
+ request_event_id: &str,
+ prev_event_id: &str,
+ listing_addr: &str,
+ buyer_pubkey: &str,
+ seller_pubkey: &str,
+ ) -> String {
+ let payload = RadrootsTradeOrderRevisionProposed {
+ revision_id: format!("revision-{event_key}"),
+ order_id: trade_order_id.to_owned(),
+ listing_addr: listing_addr.to_owned(),
+ buyer_pubkey: buyer_pubkey.to_owned(),
+ seller_pubkey: seller_pubkey.to_owned(),
+ root_event_id: request_event_id.to_owned(),
+ prev_event_id: prev_event_id.to_owned(),
+ items: revision_test_order_items(),
+ economics: revision_test_order_economics(),
+ reason: "harvest count updated".to_owned(),
+ };
+ let parts = radroots_sdk::trade::build_order_revision_proposal_draft(
+ request_event_id,
+ prev_event_id,
+ &payload,
+ )
+ .expect("order revision proposal draft should build")
+ .into_wire_parts();
+ let record_id = format!("app:signed_event:revision-proposal:{event_key}");
+ let event_id = format!("event-{record_id}");
+ append_trade_signed_event_record(
+ paths,
+ record_id.as_str(),
+ event_id.as_str(),
+ i64::from(parts.kind),
+ seller_pubkey,
+ listing_addr,
+ json!(parts.tags),
+ parts.content,
+ );
+ event_id
+ }
+
+ fn revision_test_order_items() -> Vec<RadrootsTradeOrderItem> {
+ vec![RadrootsTradeOrderItem {
+ bin_id: "seller-order-primary-bin".to_owned(),
+ bin_count: 3,
+ }]
+ }
+
+ fn revision_test_order_economics() -> RadrootsTradeOrderEconomics {
+ RadrootsTradeOrderEconomics {
+ quote_id: "quote-revision-test".to_owned(),
+ quote_version: 2,
+ pricing_basis: RadrootsTradePricingBasis::ListingEvent,
+ currency: RadrootsCoreCurrency::USD,
+ items: vec![RadrootsTradeOrderEconomicItem {
+ bin_id: "seller-order-primary-bin".to_owned(),
+ bin_count: 3,
+ quantity_amount: RadrootsCoreDecimal::from(1u32),
+ quantity_unit: RadrootsCoreUnit::Each,
+ unit_price_amount: RadrootsCoreDecimal::from(8u32),
+ unit_price_currency: RadrootsCoreCurrency::USD,
+ line_subtotal: RadrootsCoreMoney::from_minor_units_u32(
+ 2400,
+ RadrootsCoreCurrency::USD,
+ ),
+ }],
+ discounts: Vec::new(),
+ adjustments: Vec::new(),
+ subtotal: RadrootsCoreMoney::from_minor_units_u32(2400, RadrootsCoreCurrency::USD),
+ discount_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD),
+ adjustment_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD),
+ total: RadrootsCoreMoney::from_minor_units_u32(2400, RadrootsCoreCurrency::USD),
+ }
+ }
+
+ fn assert_order_lifecycle_evidence_invalid(error: AppSqliteError) {
+ assert!(
+ matches!(
+ error,
+ AppSqliteError::InvalidProjection {
+ reason: "order lifecycle evidence is invalid"
+ }
+ ),
+ "{error:?}"
+ );
+ }
+
fn append_trade_signed_event_record(
paths: &AppDesktopRuntimePaths,
record_id: &str,