app

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

commit 9eee9e9f12bafb7ff9bdf1d929c68ab7f28d90c0
parent 0d2cee1d8a8ab6f42fd5b2f1ce67fbdc4e01e139
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 23:16:02 -0700

app: add passive validation receipt evidence

- add separate validation receipt projections for order detail views
- attach receipts through signed request roots without reducer mutation
- render localized buyer and seller validation evidence
- cover out-of-order, invalid, and duplicate receipt imports

Diffstat:
Mcrates/desktop/src/source_guards.rs | 19+++++++++++++++++++
Mcrates/desktop/src/window.rs | 135++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/i18n/src/keys.rs | 18++++++++++++++++++
Mcrates/i18n/src/lib.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/state/src/lib.rs | 1+
Acrates/store/migrations/0026_order_validation_receipt_projection.sql | 28++++++++++++++++++++++++++++
Acrates/store/migrations/0027_local_interop_validation_receipt_projection_kind.sql | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/store/src/interop.rs | 834++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/store/src/lib.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/store/src/migrations.rs | 10++++++++++
Mcrates/store/src/repo/buyer.rs | 4+++-
Mcrates/store/src/repo/order_detail.rs | 131++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/store/src/repo/orders.rs | 4+++-
Mcrates/view/src/lib.rs | 196++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mi18n/locales/en/messages.json | 18++++++++++++++++++
15 files changed, 1597 insertions(+), 28 deletions(-)

diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -330,6 +330,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "{} {} {}.", "{quantity} {unit_label}", "{} {}", + "{}...", ]; const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ @@ -496,6 +497,24 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::OrdersDetailWindowLabel", "AppTextKey::OrdersDetailPickupLabel", "AppTextKey::OrdersDetailTotalLabel", + "AppTextKey::TradeValidationReceiptSectionLabel", + "AppTextKey::TradeValidationReceiptEventLabel", + "AppTextKey::TradeValidationReceiptTargetLabel", + "AppTextKey::TradeValidationReceiptEventSetRootLabel", + "AppTextKey::TradeValidationReceiptReducerOutputRootLabel", + "AppTextKey::TradeValidationReceiptPublicValuesHashLabel", + "AppTextKey::TradeValidationReceiptRecordedAtLabel", + "AppTextKey::TradeValidationReceiptResultValid", + "AppTextKey::TradeValidationReceiptResultNeedsReview", + "AppTextKey::TradeValidationReceiptTypeListingValidation", + "AppTextKey::TradeValidationReceiptTypeTradeTransition", + "AppTextKey::TradeValidationReceiptTypeInventoryState", + "AppTextKey::TradeValidationReceiptTypeStateCheckpoint", + "AppTextKey::TradeValidationReceiptProofNone", + "AppTextKey::TradeValidationReceiptProofSp1Core", + "AppTextKey::TradeValidationReceiptProofSp1Compressed", + "AppTextKey::TradeValidationReceiptProofSp1Groth16", + "AppTextKey::TradeValidationReceiptProofSp1Plonk", "AppTextKey::TradeWorkflowAxisAgreement", "AppTextKey::TradeWorkflowAxisRevision", "AppTextKey::TradeWorkflowAxisFulfillment", diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -67,7 +67,9 @@ use radroots_app_view::{ RepeatDemandEligibility, RepeatDemandHandoffProjection, ShellSection, TodayAgendaProjection, TodaySetupTaskKind, TradeAgreementStatus, TradeEconomicsProjection, TradeFulfillmentStatus, TradeInventoryStatus, TradePaymentDisplayStatus, TradeReceiptProjection, TradeRevisionStatus, - TradeWorkflowProjection, TradeWorkflowSource, + TradeValidationReceiptProjection, TradeValidationReceiptProofSystem, + TradeValidationReceiptResult, TradeValidationReceiptType, TradeWorkflowProjection, + TradeWorkflowSource, }; use radroots_nostr::prelude::RadrootsNostrClient; use std::{ @@ -4336,6 +4338,11 @@ impl HomeView { trade_economics_total_text(&detail.workflow.economics), ), ])) + .when(!detail.validation_receipts.is_empty(), |this| { + this.child(validation_receipts_summary_section( + &detail.validation_receipts, + )) + }) .child(app_form_section( app_shared_text(AppTextKey::OrdersDetailItemsTitle), div() @@ -9128,6 +9135,11 @@ fn buyer_order_detail_card( .when_some(detail.workflow.receipt.as_ref(), |this, receipt| { this.child(buyer_receipt_summary_section(receipt)) }) + .when(!detail.validation_receipts.is_empty(), |this| { + this.child(validation_receipts_summary_section( + &detail.validation_receipts, + )) + }) .child(app_form_section( app_shared_text(AppTextKey::PersonalOrdersDetailItemsTitle), div() @@ -9324,6 +9336,71 @@ fn buyer_receipt_summary_section(receipt: &TradeReceiptProjection) -> AnyElement .into_any_element() } +fn validation_receipts_summary_section( + receipts: &[TradeValidationReceiptProjection], +) -> AnyElement { + app_form_section( + app_shared_text(AppTextKey::TradeValidationReceiptSectionLabel), + app_stack_v(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .children( + receipts + .iter() + .map(validation_receipt_summary_panel) + .collect::<Vec<_>>(), + ), + ) + .into_any_element() +} + +fn validation_receipt_summary_panel(receipt: &TradeValidationReceiptProjection) -> AnyElement { + app_surface_panel( + app_stack_v(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .p(px(APP_UI_THEME.shells.home_card_padding_px)) + .child( + app_cluster(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .child(trade_workflow_value_badge(validation_receipt_result_key( + receipt.result, + ))) + .child(trade_workflow_value_badge(validation_receipt_type_key( + receipt.receipt_type, + ))) + .child(trade_workflow_value_badge( + validation_receipt_proof_system_key(receipt.proof_system), + )), + ) + .child(label_value_list([ + LabelValueRow::new( + app_shared_text(AppTextKey::TradeValidationReceiptEventLabel), + short_evidence_text(receipt.event_id.as_str()), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::TradeValidationReceiptTargetLabel), + short_evidence_text(receipt.target_event_id.as_str()), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::TradeValidationReceiptEventSetRootLabel), + short_evidence_text(receipt.event_set_root.as_str()), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::TradeValidationReceiptReducerOutputRootLabel), + short_evidence_text(receipt.reducer_output_root.as_str()), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::TradeValidationReceiptPublicValuesHashLabel), + short_evidence_text(receipt.public_values_hash.as_str()), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::TradeValidationReceiptRecordedAtLabel), + receipt.recorded_at.to_string(), + ), + ])), + ) + .into_any_element() +} + fn buyer_receipt_issue_form_section( form: &BuyerReceiptIssueFormState, cx: &mut Context<HomeView>, @@ -9390,6 +9467,60 @@ fn buyer_receipt_status_key(receipt: &TradeReceiptProjection) -> AppTextKey { } } +fn validation_receipt_result_key(result: TradeValidationReceiptResult) -> AppTextKey { + match result { + TradeValidationReceiptResult::Valid => AppTextKey::TradeValidationReceiptResultValid, + TradeValidationReceiptResult::NeedsReview => { + AppTextKey::TradeValidationReceiptResultNeedsReview + } + } +} + +fn validation_receipt_type_key(receipt_type: TradeValidationReceiptType) -> AppTextKey { + match receipt_type { + TradeValidationReceiptType::ListingValidation => { + AppTextKey::TradeValidationReceiptTypeListingValidation + } + TradeValidationReceiptType::TradeTransition => { + AppTextKey::TradeValidationReceiptTypeTradeTransition + } + TradeValidationReceiptType::InventoryState => { + AppTextKey::TradeValidationReceiptTypeInventoryState + } + TradeValidationReceiptType::StateCheckpoint => { + AppTextKey::TradeValidationReceiptTypeStateCheckpoint + } + } +} + +fn validation_receipt_proof_system_key( + proof_system: TradeValidationReceiptProofSystem, +) -> AppTextKey { + match proof_system { + TradeValidationReceiptProofSystem::None => AppTextKey::TradeValidationReceiptProofNone, + TradeValidationReceiptProofSystem::Sp1Core => { + AppTextKey::TradeValidationReceiptProofSp1Core + } + TradeValidationReceiptProofSystem::Sp1Compressed => { + AppTextKey::TradeValidationReceiptProofSp1Compressed + } + TradeValidationReceiptProofSystem::Sp1Groth16 => { + AppTextKey::TradeValidationReceiptProofSp1Groth16 + } + TradeValidationReceiptProofSystem::Sp1Plonk => { + AppTextKey::TradeValidationReceiptProofSp1Plonk + } + } +} + +fn short_evidence_text(value: &str) -> String { + if value.len() <= 16 { + value.to_owned() + } else { + format!("{}...", &value[..16]) + } +} + fn buyer_repeat_demand_action_label(repeat_demand: &RepeatDemandHandoffProjection) -> SharedString { match repeat_demand.eligibility { RepeatDemandEligibility::Eligible => { @@ -14400,6 +14531,7 @@ mod tests { order_id, BuyerOrderStatus::Placed, ), + validation_receipts: Vec::new(), order_note: None, repeat_demand: Some(RepeatDemandHandoffProjection { order_id, @@ -14548,6 +14680,7 @@ mod tests { farmer_order_id, OrderStatus::Scheduled, ), + validation_receipts: Vec::new(), primary_action: Some(OrderPrimaryAction::PublishPreparing), fulfillment_actions: OrderFulfillmentAction::ALL.to_vec(), recoveries: Vec::new(), diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -230,6 +230,24 @@ define_app_text_keys! { OrdersDetailPickupLabel => "orders.detail.pickup.label", OrdersDetailTotalLabel => "orders.detail.total.label", OrdersDetailPaymentLabel => "orders.detail.payment.label", + TradeValidationReceiptSectionLabel => "trade.validation.section.label", + TradeValidationReceiptEventLabel => "trade.validation.event.label", + TradeValidationReceiptTargetLabel => "trade.validation.target.label", + TradeValidationReceiptEventSetRootLabel => "trade.validation.event_set_root.label", + TradeValidationReceiptReducerOutputRootLabel => "trade.validation.reducer_output_root.label", + TradeValidationReceiptPublicValuesHashLabel => "trade.validation.public_values_hash.label", + TradeValidationReceiptRecordedAtLabel => "trade.validation.recorded_at.label", + TradeValidationReceiptResultValid => "trade.validation.result.valid", + TradeValidationReceiptResultNeedsReview => "trade.validation.result.needs_review", + TradeValidationReceiptTypeListingValidation => "trade.validation.type.listing_validation", + TradeValidationReceiptTypeTradeTransition => "trade.validation.type.trade_transition", + TradeValidationReceiptTypeInventoryState => "trade.validation.type.inventory_state", + TradeValidationReceiptTypeStateCheckpoint => "trade.validation.type.state_checkpoint", + TradeValidationReceiptProofNone => "trade.validation.proof.none", + TradeValidationReceiptProofSp1Core => "trade.validation.proof.sp1_core", + TradeValidationReceiptProofSp1Compressed => "trade.validation.proof.sp1_compressed", + TradeValidationReceiptProofSp1Groth16 => "trade.validation.proof.sp1_groth16", + TradeValidationReceiptProofSp1Plonk => "trade.validation.proof.sp1_plonk", OrdersRecoverySectionTitle => "orders.recovery.section.title", OrdersRecoveryMissedPickupTitle => "orders.recovery.missed_pickup.title", OrdersRecoveryMissedPickupBody => "orders.recovery.missed_pickup.body", diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs @@ -600,6 +600,70 @@ mod tests { } #[test] + fn validation_receipt_copy_covers_passive_evidence() { + for (key, expected) in [ + (AppTextKey::TradeValidationReceiptSectionLabel, "Validation"), + (AppTextKey::TradeValidationReceiptEventLabel, "Receipt"), + (AppTextKey::TradeValidationReceiptTargetLabel, "Target"), + ( + AppTextKey::TradeValidationReceiptEventSetRootLabel, + "Event set", + ), + ( + AppTextKey::TradeValidationReceiptReducerOutputRootLabel, + "Output root", + ), + ( + AppTextKey::TradeValidationReceiptPublicValuesHashLabel, + "Values hash", + ), + ( + AppTextKey::TradeValidationReceiptRecordedAtLabel, + "Recorded", + ), + (AppTextKey::TradeValidationReceiptResultValid, "Valid"), + ( + AppTextKey::TradeValidationReceiptResultNeedsReview, + "Needs review", + ), + ( + AppTextKey::TradeValidationReceiptTypeListingValidation, + "Listing", + ), + ( + AppTextKey::TradeValidationReceiptTypeTradeTransition, + "Trade", + ), + ( + AppTextKey::TradeValidationReceiptTypeInventoryState, + "Stock", + ), + ( + AppTextKey::TradeValidationReceiptTypeStateCheckpoint, + "State", + ), + (AppTextKey::TradeValidationReceiptProofNone, "None"), + (AppTextKey::TradeValidationReceiptProofSp1Core, "SP1 core"), + ( + AppTextKey::TradeValidationReceiptProofSp1Compressed, + "SP1 compressed", + ), + ( + AppTextKey::TradeValidationReceiptProofSp1Groth16, + "SP1 Groth16", + ), + (AppTextKey::TradeValidationReceiptProofSp1Plonk, "SP1 Plonk"), + ] { + assert_eq!(app_text(key), expected); + assert!( + app_text(key).split_whitespace().count() <= 3, + "{} is too long for compact validation receipt evidence", + key.id() + ); + } + } + + #[test] fn english_marketplace_orders_copy_matches_the_buyer_history_contract() { assert_eq!( app_text(AppTextKey::PersonalOrdersSurfaceBody), diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs @@ -2558,6 +2558,7 @@ mod tests { OrderStatus::NeedsAction, ) .with_economics_and_payment(order_economics, order_payment), + validation_receipts: Vec::new(), primary_action: Some(OrderPrimaryAction::Review), fulfillment_actions: Vec::new(), recoveries: Vec::new(), diff --git a/crates/store/migrations/0026_order_validation_receipt_projection.sql b/crates/store/migrations/0026_order_validation_receipt_projection.sql @@ -0,0 +1,28 @@ +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) +); + +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); diff --git a/crates/store/migrations/0027_local_interop_validation_receipt_projection_kind.sql b/crates/store/migrations/0027_local_interop_validation_receipt_projection_kind.sql @@ -0,0 +1,103 @@ +DROP INDEX IF EXISTS idx_local_interop_imports_seq; +DROP INDEX IF EXISTS idx_local_interop_imports_owner_status; +DROP INDEX IF EXISTS idx_local_interop_imports_projected; + +ALTER TABLE local_interop_imports RENAME TO local_interop_imports_validation_receipt_projection_kind_legacy; + +CREATE TABLE local_interop_imports ( + record_id TEXT PRIMARY KEY NOT NULL, + local_seq INTEGER NOT NULL CHECK (local_seq >= 0), + record_family TEXT NOT NULL CHECK (record_family IN ('local_work', 'signed_event')), + local_status TEXT NOT NULL CHECK ( + local_status IN ( + 'local_draft', + 'local_saved', + 'pending_publish', + 'published', + 'failed', + 'conflict' + ) + ), + source_runtime TEXT NOT NULL, + owner_account_id TEXT, + owner_pubkey TEXT, + farm_key TEXT, + listing_addr TEXT, + projected_kind TEXT NOT NULL CHECK ( + projected_kind IN ('farm', 'listing', 'signed_event', 'validation_receipt', 'unsupported') + ), + projected_id TEXT, + event_id TEXT, + event_kind INTEGER, + outbox_status TEXT NOT NULL CHECK ( + outbox_status IN ('none', 'pending', 'acknowledged', 'failed') + ), + relay_delivery_json TEXT, + imported_at TEXT NOT NULL, + event_pubkey TEXT, + event_created_at INTEGER, + event_tags_json TEXT, + event_content TEXT, + event_sig TEXT, + raw_event_json TEXT +); + +INSERT INTO local_interop_imports ( + record_id, + local_seq, + record_family, + local_status, + source_runtime, + owner_account_id, + owner_pubkey, + farm_key, + listing_addr, + projected_kind, + projected_id, + event_id, + event_kind, + outbox_status, + relay_delivery_json, + imported_at, + event_pubkey, + event_created_at, + event_tags_json, + event_content, + event_sig, + raw_event_json +) +SELECT + record_id, + local_seq, + record_family, + local_status, + source_runtime, + owner_account_id, + owner_pubkey, + farm_key, + listing_addr, + projected_kind, + projected_id, + event_id, + event_kind, + outbox_status, + relay_delivery_json, + imported_at, + event_pubkey, + event_created_at, + event_tags_json, + event_content, + event_sig, + raw_event_json +FROM local_interop_imports_validation_receipt_projection_kind_legacy; + +CREATE INDEX idx_local_interop_imports_seq + ON local_interop_imports(local_seq); + +CREATE INDEX idx_local_interop_imports_owner_status + ON local_interop_imports(owner_account_id, local_status, local_seq DESC); + +CREATE INDEX idx_local_interop_imports_projected + ON local_interop_imports(projected_kind, projected_id); + +DROP TABLE local_interop_imports_validation_receipt_projection_kind_legacy; diff --git a/crates/store/src/interop.rs b/crates/store/src/interop.rs @@ -3,8 +3,9 @@ use std::{fs, path::Path}; use radroots_app_view::{ FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, FulfillmentWindowId, OrderId, PickupLocationId, ProductId, ProductStatus, - TradeProvenanceProjection, TradeRevisionStatus, TradeWorkflowProjection, TradeWorkflowSource, - order_status_from_active_order_projection, + TradeProvenanceProjection, TradeRevisionStatus, TradeValidationReceiptProofSystem, + TradeValidationReceiptResult, TradeValidationReceiptType, TradeWorkflowProjection, + TradeWorkflowSource, order_status_from_active_order_projection, }; use radroots_events::{ RadrootsNostrEvent, @@ -13,7 +14,7 @@ use radroots_events::{ KIND_LISTING_DRAFT as RADROOTS_KIND_LISTING_DRAFT, KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_RESPONSE, KIND_TRADE_ORDER_REVISION, KIND_TRADE_ORDER_REVISION_RESPONSE, KIND_TRADE_PAYMENT_RECORDED, - KIND_TRADE_RECEIPT, KIND_TRADE_SETTLEMENT_DECISION, + KIND_TRADE_RECEIPT, KIND_TRADE_SETTLEMENT_DECISION, KIND_TRADE_VALIDATION_RECEIPT, }, trade::{ RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, @@ -41,6 +42,9 @@ use radroots_trade::order::{ RadrootsActiveOrderRevisionProposalRecord, RadrootsActiveOrderSettlementRecord, reduce_active_order_events, }; +use radroots_trade::validation_receipt::{ + RadrootsTradeValidationReceipt, RadrootsValidationReceiptTags, validation_receipt_from_event, +}; use rusqlite::{Connection, OptionalExtension, params}; use serde_json::Value; use uuid::Uuid; @@ -62,6 +66,7 @@ const KIND_ORDER_FULFILLMENT: i64 = KIND_TRADE_FULFILLMENT_UPDATE as i64; const KIND_ORDER_RECEIPT: i64 = KIND_TRADE_RECEIPT as i64; const KIND_ORDER_PAYMENT: i64 = KIND_TRADE_PAYMENT_RECORDED as i64; const KIND_ORDER_SETTLEMENT: i64 = KIND_TRADE_SETTLEMENT_DECISION as i64; +const KIND_VALIDATION_RECEIPT: i64 = KIND_TRADE_VALIDATION_RECEIPT as i64; const ACTIVE_ORDER_EVENT_KINDS: [i64; 9] = [ KIND_ORDER_REQUEST, KIND_ORDER_DECISION, @@ -312,6 +317,69 @@ impl<'a> AppLocalInteropRepository<'a> { Ok(events) } + fn load_signed_event_by_event_id( + &self, + event_id: &str, + ) -> Result<Option<RadrootsNostrEvent>, 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(params![event_id], |row| { + Ok(StoredLocalInteropSignedEventEvidence { + event_id: row.get(0)?, + event_kind: row.get(1)?, + local_status: row.get(2)?, + outbox_status: row.get(3)?, + relay_delivery_json: row.get(4)?, + event_pubkey: row.get(5)?, + event_created_at: row.get(6)?, + event_tags_json: row.get(7)?, + event_content: row.get(8)?, + event_sig: row.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 @@ -698,6 +766,7 @@ impl<'a> AppLocalInteropRepository<'a> { match record.event_kind { Some(KIND_FARM) => self.import_signed_farm(record), Some(KIND_LISTING | KIND_LISTING_DRAFT) => 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", @@ -1066,13 +1135,38 @@ impl<'a> AppLocalInteropRepository<'a> { Ok(Some(signed_event_projection(record))) } + fn import_signed_validation_receipt( + &self, + record: &LocalEventRecord, + ) -> 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 Ok(verified) = validation_receipt_from_event(&event) else { + return Ok(Some(signed_event_projection(record))); + }; + self.upsert_validation_receipt_projection(&event, &verified.receipt, &verified.tags)?; + Ok(Some(ProjectionRecord { + kind: "validation_receipt", + projected_id: Some(event.id), + })) + } + fn project_active_order( &self, record: &LocalEventRecord, current_evidence: ActiveOrderEvidence, ) -> Result<(), AppSqliteError> { - if let Some(payload) = current_evidence.request_payload() { - self.upsert_order_request(record, payload)?; + if let ActiveOrderEvidence::Request(request) = &current_evidence { + let order_id = self.upsert_order_request(record, &request.payload)?; + 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.order_id())?; evidence.push(current_evidence); @@ -1262,6 +1356,122 @@ impl<'a> AppLocalInteropRepository<'a> { Ok(()) } + fn upsert_validation_receipt_projection( + &self, + event: &RadrootsNostrEvent, + 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 result = TradeValidationReceiptResult::from_validation_receipt_result(receipt.result); + let receipt_type = + TradeValidationReceiptType::from_validation_receipt_type(receipt.receipt_type); + let proof_system = TradeValidationReceiptProofSystem::from_validation_receipt_proof_system( + receipt.proof.system, + ); + + self.connection + .execute( + "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 + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ON CONFLICT(event_id) DO UPDATE SET + order_id = excluded.order_id, + raw_order_id = excluded.raw_order_id, + root_event_id = excluded.root_event_id, + listing_event_id = excluded.listing_event_id, + target_event_id = excluded.target_event_id, + receipt_type = excluded.receipt_type, + result = excluded.result, + proof_system = excluded.proof_system, + event_set_root = excluded.event_set_root, + reducer_output_root = excluded.reducer_output_root, + public_values_hash = excluded.public_values_hash, + event_created_at = excluded.event_created_at", + params![ + event.id.as_str(), + order_id.map(|order_id| order_id.to_string()), + tags.order_id.as_str(), + tags.root_event_id.as_str(), + tags.listing_event_id.as_str(), + tags.target_event_id.as_str(), + receipt_type.storage_key(), + result.storage_key(), + proof_system.storage_key(), + tags.event_set_root.as_str(), + tags.reducer_output_root.as_str(), + tags.public_values_hash.as_str(), + i64::from(event.created_at), + ], + ) + .map_err(|source| AppSqliteError::Query { + operation: "upsert local interop validation receipt projection", + source, + })?; + 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) = active_trade_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(), + ), + )) + } + + fn attach_validation_receipts_for_request( + &self, + root_event_id: &str, + raw_order_id: &str, + order_id: OrderId, + ) -> 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", + params![root_event_id, raw_order_id, order_id.to_string()], + ) + .map_err(|source| AppSqliteError::Query { + operation: "attach local interop validation receipts to request", + source, + })?; + Ok(()) + } + fn load_active_order_evidence( &self, order_id: &str, @@ -2328,6 +2538,13 @@ 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, @@ -2535,20 +2752,6 @@ impl ActiveOrderEvidence { } } - fn request_payload(&self) -> Option<&RadrootsTradeOrderRequested> { - match self { - Self::Request(record) => Some(&record.payload), - Self::Decision(_) - | Self::RevisionProposal(_) - | Self::RevisionDecision(_) - | Self::Fulfillment(_) - | Self::Cancellation(_) - | Self::Receipt(_) - | Self::Payment(_) - | Self::Settlement(_) => None, - } - } - fn order_projection_identity(&self) -> (&str, &str) { match self { Self::Request(record) => ( @@ -3660,13 +3863,15 @@ mod tests { BuyerContext, BuyerOrderStatus, FarmId, FarmOrderMethod, OrderFulfillmentAction, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersScreenQueryState, ProductAvailabilityState, ProductId, TradeFulfillmentStatus, TradeInventoryStatus, - TradePaymentDisplayStatus, TradeRevisionStatus, TradeWorkflowSource, + TradePaymentDisplayStatus, TradeRevisionStatus, TradeValidationReceiptProofSystem, + TradeValidationReceiptResult, TradeValidationReceiptType, TradeWorkflowSource, }; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, }; use radroots_events::{ RadrootsNostrEvent, RadrootsNostrEventPtr, + kinds::KIND_TRADE_RECEIPT, trade::{ RadrootsActiveTradeFulfillmentState, RadrootsTradeBuyerReceipt, RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, @@ -3696,14 +3901,23 @@ mod tests { LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, SourceRuntime, }; use radroots_sql_core::SqliteExecutor; - use radroots_trade::order::radroots_trade_order_economics_digest; + use radroots_trade::{ + order::radroots_trade_order_economics_digest, + validation_receipt::{ + RadrootsTradeValidationReceipt, RadrootsValidationReceiptProof, + RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult, + RadrootsValidationReceiptStatement, RadrootsValidationReceiptType, + VALIDATION_RECEIPT_DOMAIN, VALIDATION_RECEIPT_VERSION, validation_receipt_event_build, + }, + }; use rusqlite::params; use serde_json::json; use uuid::Uuid; use super::{ - KIND_FARM, KIND_LISTING, KIND_ORDER_REQUEST, deterministic_farm_id, - deterministic_product_id, projected_farm_id, projected_order_id, projected_product_id, + 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, }; use crate::{AppSqliteStore, BuyerRepeatDemandApplyOutcome, DatabaseTarget}; @@ -4358,6 +4572,21 @@ mod tests { fulfillment_event_id: String, } + struct ValidationReceiptOrderFixture { + app_store: AppSqliteStore, + events: LocalEventsStore<SqliteExecutor>, + 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 active_order_ready_fixture(label: &str) -> ActiveOrderReadyFixture { let app_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); @@ -4497,6 +4726,117 @@ mod tests { } } + fn validation_receipt_order_fixture(label: &str) -> ValidationReceiptOrderFixture { + let app_store = + AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); + let events = local_events_store(); + let farm_key = "DDDDDDDDDDDDDDDDDDDDDD"; + let listing_key = "AAAAAAAAAAAAAAAAAAAAAw"; + let seller_pubkey = format!("{label}-seller"); + let buyer_pubkey = format!("{label}-buyer"); + 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, + LocalRecordStatus::Published, + PublishOutboxStatus::Acknowledged, + )) + .expect("append signed listing"); + app_store + .import_shared_local_events_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 = active_trade_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_local_events_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 = active_trade_order_decision_event_build( + request_event_id.as_str(), + 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_local_events_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()), + order_id_raw, + listing_addr, + buyer_pubkey, + seller_pubkey, + listing_event_id, + request_event_id, + decision_event_id, + } + } + fn payment_recorded_payload( request: &RadrootsTradeOrderRequested, root_event_id: &str, @@ -4565,6 +4905,83 @@ mod tests { } } + fn event_from_parts_at( + event_id: &str, + author: &str, + parts: WireEventParts, + created_at: u32, + ) -> RadrootsNostrEvent { + let mut event = event_from_parts(event_id, author, parts); + event.created_at = created_at; + event + } + + fn hex_event_id(seed: u8) -> String { + format!("{seed:064x}") + } + + 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_receipt_payload( + listing_event_id: &str, + root_event_id: &str, + target_event_id: &str, + result: RadrootsValidationReceiptResult, + ) -> RadrootsTradeValidationReceipt { + RadrootsTradeValidationReceipt { + changed_records_root: hash32(41), + domain: VALIDATION_RECEIPT_DOMAIN.to_owned(), + error_bitmap: validation_error_bitmap(result), + event_set_root: hash32(42), + new_state_root: hash32(43), + previous_state_root: hash32(44), + proof: RadrootsValidationReceiptProof { + inline_proof_base64: None, + mode: None, + program_hash: None, + proof_reference: None, + system: RadrootsValidationReceiptProofSystem::None, + verifying_key_hash: None, + }, + public_values_hash: hash32(45), + receipt_type: RadrootsValidationReceiptType::TradeTransition, + result, + statement: RadrootsValidationReceiptStatement { + listing_event_id: listing_event_id.to_owned(), + root_event_id: root_event_id.to_owned(), + target_event_id: target_event_id.to_owned(), + statement_type: RadrootsValidationReceiptType::TradeTransition, + }, + version: VALIDATION_RECEIPT_VERSION, + } + } + + fn validation_receipt_event( + event_id: &str, + author: &str, + order_id: &str, + listing_event_id: &str, + root_event_id: &str, + target_event_id: &str, + result: RadrootsValidationReceiptResult, + created_at: u32, + ) -> RadrootsNostrEvent { + let receipt = + validation_receipt_payload(listing_event_id, root_event_id, target_event_id, result); + let parts = + validation_receipt_event_build(order_id, &receipt).expect("validation receipt parts"); + event_from_parts_at(event_id, author, parts, created_at) + } + fn signed_order_event_record( record_id: &str, event: &RadrootsNostrEvent, @@ -5576,6 +5993,377 @@ mod tests { } #[test] + fn validation_receipts_project_passively_on_buyer_and_seller_order_details() { + let fixture = validation_receipt_order_fixture("validation-receipt-passive"); + 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_local_events_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::Scheduled); + assert_eq!(seller_detail.status, OrderStatus::Scheduled); + assert!(buyer_detail.workflow.receipt.is_none()); + assert!(seller_detail.workflow.receipt.is_none()); + assert_eq!( + buyer_detail.workflow.payment, + TradePaymentDisplayStatus::NotRecorded + ); + assert_eq!( + seller_detail.workflow.payment, + TradePaymentDisplayStatus::NotRecorded + ); + assert_eq!( + seller_detail.primary_action, + Some(OrderPrimaryAction::PublishPreparing) + ); + assert_eq!( + seller_detail.fulfillment_actions, + OrderFulfillmentAction::ALL.to_vec() + ); + 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 + ); + 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); + 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.as_str())) + ); + } + + #[test] + fn validation_receipt_import_before_order_request_attaches_when_request_arrives() { + let app_store = + AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); + let events = local_events_store(); + let farm_key = "DDDDDDDDDDDDDDDDDDDDDD"; + let listing_key = "AAAAAAAAAAAAAAAAAAAAAw"; + let seller_pubkey = "validation-out-of-order-seller"; + let buyer_pubkey = "validation-out-of-order-buyer"; + 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, + LocalRecordStatus::Published, + PublishOutboxStatus::Acknowledged, + )) + .expect("append out-of-order listing"); + app_store + .import_shared_local_events_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(), + 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(), + SourceRuntime::Cli, + None, + )) + .expect("append receipt before request"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import receipt before request"); + + let pending_count: i64 = app_store + .connection() + .query_row( + "SELECT count(*) FROM order_validation_receipts WHERE order_id IS NULL", + [], + |row| row.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 = active_trade_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_local_events_from_store(&events) + .expect("import request after receipt"); + + let order_id = projected_order_id(order_id_raw, buyer_pubkey); + 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 + ); + assert_eq!(buyer_detail.status, BuyerOrderStatus::Placed); + assert_eq!(seller_detail.status, OrderStatus::NeedsAction); + assert!(buyer_detail.workflow.receipt.is_none()); + assert!(seller_detail.workflow.receipt.is_none()); + } + + #[test] + fn validation_receipt_invalid_candidates_do_not_surface_as_order_evidence() { + let fixture = validation_receipt_order_fixture("validation-receipt-invalid-candidates"); + let mut 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, + ); + if let Some(tag) = mismatched_tag_event + .tags + .iter_mut() + .find(|tag| tag.first().map(String::as_str) == Some("event_set_root")) + { + tag[1] = hash32(99); + } + 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 mut 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, + ); + buyer_kind_candidate.kind = KIND_TRADE_RECEIPT; + + 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_local_events_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::Scheduled); + assert_eq!(seller_detail.status, OrderStatus::Scheduled); + assert!(buyer_detail.workflow.receipt.is_none()); + assert!(seller_detail.workflow.receipt.is_none()); + assert_eq!( + seller_detail.primary_action, + Some(OrderPrimaryAction::PublishPreparing) + ); + } + + #[test] fn active_order_revision_and_cancellation_project_through_cli_reducer_state() { 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 @@ -851,6 +851,7 @@ mod tests { assert!(table_exists(connection, "reminder_log_entries")); assert!(table_exists(connection, "order_recovery_records")); assert!(table_exists(connection, "buyer_order_coordination_records")); + assert!(table_exists(connection, "order_validation_receipts")); assert!(column_exists(connection, "farms", "timezone")); assert!(column_exists(connection, "farms", "currency_code")); assert!(column_exists(connection, "local_outbox", "account_id")); @@ -988,9 +989,68 @@ mod tests { )); assert!(column_exists( connection, + "order_validation_receipts", + "event_id" + )); + assert!(column_exists( + connection, + "order_validation_receipts", + "order_id" + )); + assert!(column_exists( + connection, + "order_validation_receipts", + "raw_order_id" + )); + assert!(column_exists( + connection, + "order_validation_receipts", + "root_event_id" + )); + assert!(column_exists( + connection, + "order_validation_receipts", + "target_event_id" + )); + assert!(column_exists( + connection, + "order_validation_receipts", + "result" + )); + assert!(column_exists( + connection, + "order_validation_receipts", + "proof_system" + )); + assert!(column_exists( + connection, "order_recovery_records", "recovery_state" )); + connection + .execute( + "INSERT INTO local_interop_imports ( + record_id, + local_seq, + record_family, + local_status, + source_runtime, + projected_kind, + outbox_status, + imported_at + ) VALUES ( + 'schema_validation_receipt_projection_kind', + 0, + 'signed_event', + 'published', + 'cli', + 'validation_receipt', + 'acknowledged', + '2026-01-01T00:00:00Z' + )", + [], + ) + .expect("local interop imports should accept validation receipt projections"); assert_eq!(row_count(connection, "sync_checkpoints"), 0); drop(store); diff --git a/crates/store/src/migrations.rs b/crates/store/src/migrations.rs @@ -104,6 +104,16 @@ const MIGRATIONS: &[Migration] = &[ version: 25, sql: include_str!("../migrations/0025_order_receipt_display_projection.sql"), }, + Migration { + version: 26, + sql: include_str!("../migrations/0026_order_validation_receipt_projection.sql"), + }, + Migration { + version: 27, + sql: include_str!( + "../migrations/0027_local_interop_validation_receipt_projection_kind.sql" + ), + }, ]; pub fn latest_schema_version() -> u32 { diff --git a/crates/store/src/repo/buyer.rs b/crates/store/src/repo/buyer.rs @@ -14,7 +14,7 @@ use rusqlite::{Connection, OptionalExtension, params, params_from_iter}; use serde_json::Value; use super::{ - order_detail::{order_detail_economics, order_detail_item_row}, + order_detail::{order_detail_economics, order_detail_item_row, order_validation_receipts}, parse_trade_revision_status, workflow::{StoredTradeWorkflowSnapshot, trade_workflow_projection_from_storage}, }; @@ -1070,6 +1070,7 @@ impl<'a> AppBuyerRepository<'a> { provenance_last_event_id: workflow_provenance_last_event_id, })?; let payment = workflow.payment; + let validation_receipts = order_validation_receipts(self.connection, order_id)?; Ok(BuyerOrderDetailProjection { order_id, farm_id, @@ -1085,6 +1086,7 @@ impl<'a> AppBuyerRepository<'a> { economics, payment, workflow, + validation_receipts, order_note: empty_string_to_none(order_note), repeat_demand: self.build_repeat_demand_handoff( order_id, diff --git a/crates/store/src/repo/order_detail.rs b/crates/store/src/repo/order_detail.rs @@ -1,4 +1,9 @@ -use radroots_app_view::{OrderDetailItemRow, ProductPricePresentation, TradeEconomicsProjection}; +use radroots_app_view::{ + OrderDetailItemRow, OrderId, ProductPricePresentation, TradeEconomicsProjection, + TradeValidationReceiptProjection, TradeValidationReceiptProofSystem, + TradeValidationReceiptResult, TradeValidationReceiptType, +}; +use rusqlite::{Connection, params}; use crate::AppSqliteError; @@ -80,6 +85,86 @@ pub(super) fn order_detail_economics( ) } +pub(super) fn order_validation_receipts( + connection: &Connection, + order_id: OrderId, +) -> Result<Vec<TradeValidationReceiptProjection>, AppSqliteError> { + let mut statement = connection + .prepare( + "SELECT + event_id, + result, + receipt_type, + proof_system, + event_set_root, + reducer_output_root, + public_values_hash, + target_event_id, + event_created_at + FROM order_validation_receipts + WHERE order_id = ?1 + ORDER BY event_created_at DESC, event_id DESC", + ) + .map_err(|source| AppSqliteError::Query { + operation: "prepare order validation receipts", + source, + })?; + let rows = statement + .query_map(params![order_id.to_string()], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, i64>(8)?, + )) + }) + .map_err(|source| AppSqliteError::Query { + operation: "query order validation receipts", + source, + })?; + let mut receipts = Vec::new(); + + for row in rows { + let ( + event_id, + result, + receipt_type, + proof_system, + event_set_root, + reducer_output_root, + public_values_hash, + target_event_id, + event_created_at, + ) = row.map_err(|source| AppSqliteError::Query { + operation: "read order validation receipt", + source, + })?; + + receipts.push(TradeValidationReceiptProjection { + event_id, + result: parse_validation_receipt_result(result)?, + receipt_type: parse_validation_receipt_type(receipt_type)?, + proof_system: parse_validation_receipt_proof_system(proof_system)?, + event_set_root, + reducer_output_root, + public_values_hash, + target_event_id, + recorded_at: u64::try_from(event_created_at).map_err(|_| { + AppSqliteError::InvalidProjection { + reason: "order_validation_receipts.event_created_at must be non-negative", + } + })?, + }); + } + + Ok(receipts) +} + fn normalize_currency_code(value: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { @@ -88,3 +173,47 @@ fn normalize_currency_code(value: &str) -> String { trimmed.to_ascii_uppercase() } } + +fn parse_validation_receipt_result( + value: String, +) -> Result<TradeValidationReceiptResult, AppSqliteError> { + match value.as_str() { + "valid" => Ok(TradeValidationReceiptResult::Valid), + "needs_review" => Ok(TradeValidationReceiptResult::NeedsReview), + _ => Err(AppSqliteError::DecodeEnum { + field: "order_validation_receipts.result", + value, + }), + } +} + +fn parse_validation_receipt_type( + value: String, +) -> Result<TradeValidationReceiptType, AppSqliteError> { + match value.as_str() { + "listing_validation" => Ok(TradeValidationReceiptType::ListingValidation), + "trade_transition" => Ok(TradeValidationReceiptType::TradeTransition), + "inventory_state" => Ok(TradeValidationReceiptType::InventoryState), + "state_checkpoint" => Ok(TradeValidationReceiptType::StateCheckpoint), + _ => Err(AppSqliteError::DecodeEnum { + field: "order_validation_receipts.receipt_type", + value, + }), + } +} + +fn parse_validation_receipt_proof_system( + value: String, +) -> Result<TradeValidationReceiptProofSystem, AppSqliteError> { + match value.as_str() { + "none" => Ok(TradeValidationReceiptProofSystem::None), + "sp1_core" => Ok(TradeValidationReceiptProofSystem::Sp1Core), + "sp1_compressed" => Ok(TradeValidationReceiptProofSystem::Sp1Compressed), + "sp1_groth16" => Ok(TradeValidationReceiptProofSystem::Sp1Groth16), + "sp1_plonk" => Ok(TradeValidationReceiptProofSystem::Sp1Plonk), + _ => Err(AppSqliteError::DecodeEnum { + field: "order_validation_receipts.proof_system", + value, + }), + } +} diff --git a/crates/store/src/repo/orders.rs b/crates/store/src/repo/orders.rs @@ -12,7 +12,7 @@ use radroots_app_view::{ use rusqlite::{Connection, OptionalExtension, params}; use super::{ - order_detail::{order_detail_economics, order_detail_item_row}, + order_detail::{order_detail_economics, order_detail_item_row, order_validation_receipts}, parse_trade_revision_status, workflow::{StoredTradeWorkflowSnapshot, trade_workflow_projection_from_storage}, }; @@ -174,6 +174,7 @@ impl<'a> AppOrdersRepository<'a> { provenance_last_event_id: workflow_provenance_last_event_id, })?; let payment = workflow.payment; + let validation_receipts = order_validation_receipts(self.connection, order_id)?; Ok(OrderDetailProjection { order_id, farm_id, @@ -189,6 +190,7 @@ impl<'a> AppOrdersRepository<'a> { items, economics, payment, + validation_receipts, primary_action: primary_action_for_order(status, &workflow), fulfillment_actions: fulfillment_actions_for_order(status, &workflow), workflow, diff --git a/crates/view/src/lib.rs b/crates/view/src/lib.rs @@ -8,6 +8,10 @@ use radroots_trade::order::{ RadrootsActiveOrderPaymentProjection, RadrootsActiveOrderPaymentState, RadrootsActiveOrderProjection, RadrootsActiveOrderSettlementState, RadrootsActiveOrderStatus, }; +use radroots_trade::validation_receipt::{ + RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult, + RadrootsValidationReceiptType, +}; use serde::{Deserialize, Serialize}; use std::{collections::BTreeSet, error::Error, fmt, str::FromStr}; use url::Url; @@ -1489,6 +1493,134 @@ impl TradeReceiptProjection { } } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TradeValidationReceiptResult { + #[default] + Valid, + NeedsReview, +} + +impl TradeValidationReceiptResult { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Valid => "valid", + Self::NeedsReview => "needs_review", + } + } + + pub const fn label_key_id(self) -> &'static str { + match self { + Self::Valid => "messages.trade.validation.result.valid", + Self::NeedsReview => "messages.trade.validation.result.needs_review", + } + } + + pub const fn from_validation_receipt_result(result: RadrootsValidationReceiptResult) -> Self { + match result { + RadrootsValidationReceiptResult::Valid => Self::Valid, + RadrootsValidationReceiptResult::Invalid => Self::NeedsReview, + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TradeValidationReceiptType { + ListingValidation, + #[default] + TradeTransition, + InventoryState, + StateCheckpoint, +} + +impl TradeValidationReceiptType { + pub const fn storage_key(self) -> &'static str { + match self { + Self::ListingValidation => "listing_validation", + Self::TradeTransition => "trade_transition", + Self::InventoryState => "inventory_state", + Self::StateCheckpoint => "state_checkpoint", + } + } + + pub const fn label_key_id(self) -> &'static str { + match self { + Self::ListingValidation => "messages.trade.validation.type.listing_validation", + Self::TradeTransition => "messages.trade.validation.type.trade_transition", + Self::InventoryState => "messages.trade.validation.type.inventory_state", + Self::StateCheckpoint => "messages.trade.validation.type.state_checkpoint", + } + } + + pub const fn from_validation_receipt_type(receipt_type: RadrootsValidationReceiptType) -> Self { + match receipt_type { + RadrootsValidationReceiptType::ListingValidation => Self::ListingValidation, + RadrootsValidationReceiptType::TradeTransition => Self::TradeTransition, + RadrootsValidationReceiptType::InventoryState => Self::InventoryState, + RadrootsValidationReceiptType::StateCheckpoint => Self::StateCheckpoint, + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TradeValidationReceiptProofSystem { + #[default] + None, + Sp1Core, + Sp1Compressed, + Sp1Groth16, + Sp1Plonk, +} + +impl TradeValidationReceiptProofSystem { + pub const fn storage_key(self) -> &'static str { + match self { + Self::None => "none", + Self::Sp1Core => "sp1_core", + Self::Sp1Compressed => "sp1_compressed", + Self::Sp1Groth16 => "sp1_groth16", + Self::Sp1Plonk => "sp1_plonk", + } + } + + pub const fn label_key_id(self) -> &'static str { + match self { + Self::None => "messages.trade.validation.proof.none", + Self::Sp1Core => "messages.trade.validation.proof.sp1_core", + Self::Sp1Compressed => "messages.trade.validation.proof.sp1_compressed", + Self::Sp1Groth16 => "messages.trade.validation.proof.sp1_groth16", + Self::Sp1Plonk => "messages.trade.validation.proof.sp1_plonk", + } + } + + pub const fn from_validation_receipt_proof_system( + proof_system: RadrootsValidationReceiptProofSystem, + ) -> Self { + match proof_system { + RadrootsValidationReceiptProofSystem::None => Self::None, + RadrootsValidationReceiptProofSystem::Sp1Core => Self::Sp1Core, + RadrootsValidationReceiptProofSystem::Sp1Compressed => Self::Sp1Compressed, + RadrootsValidationReceiptProofSystem::Sp1Groth16 => Self::Sp1Groth16, + RadrootsValidationReceiptProofSystem::Sp1Plonk => Self::Sp1Plonk, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct TradeValidationReceiptProjection { + pub event_id: String, + pub result: TradeValidationReceiptResult, + pub receipt_type: TradeValidationReceiptType, + pub proof_system: TradeValidationReceiptProofSystem, + pub event_set_root: String, + pub reducer_output_root: String, + pub public_values_hash: String, + pub target_event_id: String, + pub recorded_at: u64, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct TradeWorkflowProjection { pub order_id: OrderId, @@ -1861,6 +1993,7 @@ pub struct OrderDetailProjection { pub economics: TradeEconomicsProjection, pub payment: TradePaymentDisplayStatus, pub workflow: TradeWorkflowProjection, + pub validation_receipts: Vec<TradeValidationReceiptProjection>, pub primary_action: Option<OrderPrimaryAction>, pub fulfillment_actions: Vec<OrderFulfillmentAction>, pub recoveries: Vec<OrderRecoveryProjection>, @@ -1901,6 +2034,7 @@ pub struct BuyerOrderDetailProjection { pub economics: TradeEconomicsProjection, pub payment: TradePaymentDisplayStatus, pub workflow: TradeWorkflowProjection, + pub validation_receipts: Vec<TradeValidationReceiptProjection>, pub order_note: Option<String>, pub repeat_demand: Option<RepeatDemandHandoffProjection>, } @@ -2322,6 +2456,10 @@ mod tests { RadrootsActiveOrderProjection, RadrootsActiveOrderSettlementState, RadrootsActiveOrderStatus, }; + use radroots_trade::validation_receipt::{ + RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult, + RadrootsValidationReceiptType, + }; use super::{ AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, @@ -2360,7 +2498,8 @@ mod tests { TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, TradeAgreementStatus, TradeEconomicsProjection, TradeFulfillmentStatus, TradeInventoryStatus, TradePaymentDisplayStatus, TradeProvenanceProjection, - TradeRevisionStatus, TradeWorkflowProjection, TradeWorkflowSource, + TradeRevisionStatus, TradeValidationReceiptProofSystem, TradeValidationReceiptResult, + TradeValidationReceiptType, TradeWorkflowProjection, TradeWorkflowSource, order_status_from_active_order_projection, }; use std::{collections::BTreeSet, str::FromStr}; @@ -3309,6 +3448,59 @@ mod tests { } #[test] + fn validation_receipt_projection_maps_shared_receipt_metadata_passively() { + assert_eq!( + TradeValidationReceiptResult::from_validation_receipt_result( + RadrootsValidationReceiptResult::Valid + ), + TradeValidationReceiptResult::Valid + ); + assert_eq!( + TradeValidationReceiptResult::from_validation_receipt_result( + RadrootsValidationReceiptResult::Invalid + ), + TradeValidationReceiptResult::NeedsReview + ); + assert_eq!( + TradeValidationReceiptType::from_validation_receipt_type( + RadrootsValidationReceiptType::TradeTransition + ), + TradeValidationReceiptType::TradeTransition + ); + assert_eq!( + TradeValidationReceiptProofSystem::from_validation_receipt_proof_system( + RadrootsValidationReceiptProofSystem::Sp1Compressed + ), + TradeValidationReceiptProofSystem::Sp1Compressed + ); + assert_eq!(TradeValidationReceiptResult::Valid.storage_key(), "valid"); + assert_eq!( + TradeValidationReceiptResult::NeedsReview.storage_key(), + "needs_review" + ); + assert_eq!( + TradeValidationReceiptType::TradeTransition.storage_key(), + "trade_transition" + ); + assert_eq!( + TradeValidationReceiptProofSystem::Sp1Compressed.storage_key(), + "sp1_compressed" + ); + assert_eq!( + TradeValidationReceiptResult::NeedsReview.label_key_id(), + "messages.trade.validation.result.needs_review" + ); + assert_eq!( + TradeValidationReceiptType::InventoryState.label_key_id(), + "messages.trade.validation.type.inventory_state" + ); + assert_eq!( + TradeValidationReceiptProofSystem::Sp1Compressed.label_key_id(), + "messages.trade.validation.proof.sp1_compressed" + ); + } + + #[test] fn trade_workflow_projection_uses_localization_key_ids_for_visible_status_labels() { assert_eq!( TradeAgreementStatus::from_active_order_status(&RadrootsActiveOrderStatus::Requested) @@ -3765,6 +3957,7 @@ mod tests { payment: order_payment, workflow: TradeWorkflowProjection::from_order_status(order_id, OrderStatus::Scheduled) .with_economics_and_payment(order_economics, order_payment), + validation_receipts: Vec::new(), primary_action: Some(OrderPrimaryAction::PublishPreparing), fulfillment_actions: OrderFulfillmentAction::ALL.to_vec(), recoveries: Vec::new(), @@ -3931,6 +4124,7 @@ mod tests { BuyerOrderStatus::Scheduled, ) .with_economics_and_payment(buyer_order_economics, buyer_order_payment), + validation_receipts: Vec::new(), order_note: Some("Leave by the cooler".to_owned()), repeat_demand: None, }; diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -210,6 +210,24 @@ "orders.detail.pickup.label": "Pickup location", "orders.detail.total.label": "Total", "orders.detail.payment.label": "Payment", + "trade.validation.section.label": "Validation", + "trade.validation.event.label": "Receipt", + "trade.validation.target.label": "Target", + "trade.validation.event_set_root.label": "Event set", + "trade.validation.reducer_output_root.label": "Output root", + "trade.validation.public_values_hash.label": "Values hash", + "trade.validation.recorded_at.label": "Recorded", + "trade.validation.result.valid": "Valid", + "trade.validation.result.needs_review": "Needs review", + "trade.validation.type.listing_validation": "Listing", + "trade.validation.type.trade_transition": "Trade", + "trade.validation.type.inventory_state": "Stock", + "trade.validation.type.state_checkpoint": "State", + "trade.validation.proof.none": "None", + "trade.validation.proof.sp1_core": "SP1 core", + "trade.validation.proof.sp1_compressed": "SP1 compressed", + "trade.validation.proof.sp1_groth16": "SP1 Groth16", + "trade.validation.proof.sp1_plonk": "SP1 Plonk", "orders.recovery.section.title": "Recovery", "orders.recovery.missed_pickup.title": "Missed pickup", "orders.recovery.missed_pickup.body": "Use this when a buyer did not collect the order as planned.",