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:
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) = ¤t_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.",