lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit e283e5f5e2480f2bbcd65d8e55383c9b334fcdd2
parent 12c4028c311e17a4edd81f153978a62b6a06f082
Author: triesap <tyson@radroots.org>
Date:   Sat, 27 Jun 2026 21:33:30 +0000

trade: add source order workflow projection dto

- Export a source-owned trade order workflow projection DTO.
- Preserve optional event evidence for missing projection state.
- Remove binding-only discount and message fields from the source contract.
- Cover DTO registry shape plus missing and requested projection conversion.

Diffstat:
Mcrates/trade/src/dto.rs | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/trade/src/order.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 172 insertions(+), 3 deletions(-)

diff --git a/crates/trade/src/dto.rs b/crates/trade/src/dto.rs @@ -7,14 +7,16 @@ use crate::listing::{ model::{RadrootsTradeListingSubtotal, RadrootsTradeListingTotal}, validation::RadrootsTradeListing, }; +use crate::order::RadrootsTradeOrderWorkflowProjection; use crate::workflow::RadrootsTradeWorkflowState; -pub fn dto_roots() -> [RootDescriptor; 4] { +pub fn dto_roots() -> [RootDescriptor; 5] { [ RootDescriptor::new::<RadrootsTradeListing>(), RootDescriptor::new::<RadrootsTradeListingSubtotal>(), RootDescriptor::new::<RadrootsTradeListingTotal>(), RootDescriptor::new::<RadrootsTradeWorkflowState>(), + RootDescriptor::new::<RadrootsTradeOrderWorkflowProjection>(), ] } @@ -232,6 +234,7 @@ mod tests { "RadrootsTradeListingSubtotal", "RadrootsTradeListingTotal", "RadrootsTradeWorkflowState", + "RadrootsTradeOrderWorkflowProjection", ]; #[test] @@ -293,6 +296,61 @@ mod tests { ); } + #[test] + fn trade_order_workflow_projection_models_missing_state_with_optional_evidence() { + let registry = build_registry(dto_roots()); + let projection = find_struct(&registry, "RadrootsTradeOrderWorkflowProjection"); + + assert!(field_names(projection).contains(&"status")); + for optional_field in [ + "request_event_id", + "decision_event_id", + "cancellation_event_id", + "validation_receipt_event_id", + "economics", + "agreement_event_id", + "pending_revision_event_id", + "listing_addr", + "buyer_pubkey", + "seller_pubkey", + "last_event_id", + ] { + let presence = registry + .struct_field_presence("RadrootsTradeOrderWorkflowProjection", optional_field) + .expect("projection field presence"); + assert!( + !presence.required_on_deserialize, + "{optional_field} must not be required for missing projections" + ); + assert!( + presence.nullable, + "{optional_field} must be nullable for missing projections" + ); + } + } + + #[test] + fn trade_order_workflow_projection_omits_binding_only_discount_fields() { + let registry = build_registry(dto_roots()); + let projection = find_struct(&registry, "RadrootsTradeOrderWorkflowProjection"); + let fields = field_names(projection); + + for stale_field in [ + "root_event_id", + "last_message_type", + "last_discount_request", + "last_discount_offer", + "accepted_discount", + "last_discount_decline_reason", + "has_requested_discounts", + ] { + assert!( + !fields.contains(&stale_field), + "{stale_field} must not remain on the source projection contract" + ); + } + } + fn root_export_names(registry: &Registry) -> Vec<&str> { registry .roots @@ -358,4 +416,11 @@ mod tests { _ => None, } } + + fn field_names(def: &StructDef) -> Vec<&str> { + def.fields + .iter() + .map(|field| field.target.typescript.as_str()) + .collect() + } } diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs @@ -358,6 +358,9 @@ fn require_context_prev_event_id( .ok_or(RadrootsOrderEventDecodeError::MissingPreviousEventId) } +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Debug, PartialEq, Eq)] pub enum RadrootsOrderIssue { MissingRequest, @@ -584,6 +587,60 @@ impl RadrootsOrderProjection { } } +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeOrderWorkflowProjection { + pub order_id: RadrootsOrderId, + pub status: RadrootsTradeWorkflowState, + pub request_event_id: Option<RadrootsEventId>, + pub decision_event_id: Option<RadrootsEventId>, + pub cancellation_event_id: Option<RadrootsEventId>, + pub validation_receipt_event_id: Option<RadrootsEventId>, + pub lifecycle_terminal: bool, + pub economics: Option<RadrootsOrderEconomics>, + pub agreement_event_id: Option<RadrootsEventId>, + pub pending_revision_event_id: Option<RadrootsEventId>, + pub pending_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>, + pub committed_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>, + pub listing_addr: Option<RadrootsListingAddress>, + pub buyer_pubkey: Option<RadrootsPublicKey>, + pub seller_pubkey: Option<RadrootsPublicKey>, + pub last_event_id: Option<RadrootsEventId>, + pub issues: Vec<RadrootsOrderIssue>, +} + +impl From<RadrootsOrderProjection> for RadrootsTradeOrderWorkflowProjection { + fn from(projection: RadrootsOrderProjection) -> Self { + Self { + order_id: projection.order_id, + status: projection.status, + request_event_id: projection.request_event_id, + decision_event_id: projection.decision_event_id, + cancellation_event_id: projection.cancellation_event_id, + validation_receipt_event_id: projection.validation_receipt_event_id, + lifecycle_terminal: projection.lifecycle_terminal, + economics: projection.economics, + agreement_event_id: projection.agreement_event_id, + pending_revision_event_id: projection.pending_revision_event_id, + pending_inventory_reservations: projection.pending_inventory_reservations, + committed_inventory_reservations: projection.committed_inventory_reservations, + listing_addr: projection.listing_addr, + buyer_pubkey: projection.buyer_pubkey, + seller_pubkey: projection.seller_pubkey, + last_event_id: projection.last_event_id, + issues: projection.issues, + } + } +} + +impl From<&RadrootsOrderProjection> for RadrootsTradeOrderWorkflowProjection { + fn from(projection: &RadrootsOrderProjection) -> Self { + projection.clone().into() + } +} + #[cfg(feature = "serde_json")] #[derive(Debug, Error)] pub enum RadrootsOrderEconomicsDigestError { @@ -2380,8 +2437,8 @@ mod tests { RadrootsOrderDecisionRecord, RadrootsOrderEventRecord, RadrootsOrderIssue, RadrootsOrderReductionInputs, RadrootsOrderRequestRecord, RadrootsOrderRevisionDecisionRecord, RadrootsOrderRevisionProposalRecord, - RadrootsTradeWorkflowState, reduce_listing_inventory_accounting, - reduce_order_event_records, reduce_order_events, + RadrootsTradeOrderWorkflowProjection, RadrootsTradeWorkflowState, + reduce_listing_inventory_accounting, reduce_order_event_records, reduce_order_events, }; use core::mem::discriminant; use radroots_core::{ @@ -4572,6 +4629,53 @@ mod tests { } #[test] + fn workflow_projection_dto_preserves_missing_and_requested_state() { + let missing = reduce_order_events( + &order_id("missing-order"), + RadrootsOrderReductionInputs { + requests: Vec::<RadrootsOrderRequestRecord>::new(), + decisions: Vec::<RadrootsOrderDecisionRecord>::new(), + revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(), + revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(), + cancellations: Vec::<RadrootsOrderCancellationRecord>::new(), + }, + ); + let missing_dto = RadrootsTradeOrderWorkflowProjection::from(&missing); + + assert_eq!(missing_dto.status, RadrootsTradeWorkflowState::Missing); + assert!(missing_dto.request_event_id.is_none()); + assert!(missing_dto.last_event_id.is_none()); + assert!(missing_dto.listing_addr.is_none()); + assert!(missing_dto.buyer_pubkey.is_none()); + assert!(missing_dto.seller_pubkey.is_none()); + assert!(missing_dto.economics.is_none()); + assert!(missing_dto.issues.is_empty()); + + #[cfg(feature = "serde_json")] + { + let json = serde_json::to_value(&missing_dto).expect("missing projection json"); + assert_eq!(json["status"], "missing"); + assert!(json["request_event_id"].is_null()); + assert!(json["last_event_id"].is_null()); + assert!(json.get("root_event_id").is_none()); + assert!(json.get("last_message_type").is_none()); + assert!(json.get("last_discount_request").is_none()); + } + + let requested = reduce(Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let requested_dto = RadrootsTradeOrderWorkflowProjection::from(&requested); + + assert_eq!(requested_dto.status, RadrootsTradeWorkflowState::Requested); + assert_eq!(requested_dto.request_event_id, Some(event_id(1))); + assert_eq!(requested_dto.last_event_id, Some(event_id(1))); + assert_eq!(requested_dto.listing_addr, Some(listing_addr())); + assert_eq!(requested_dto.buyer_pubkey, Some(public_key(BUYER))); + assert_eq!(requested_dto.seller_pubkey, Some(public_key(SELLER))); + assert!(requested_dto.economics.is_some()); + assert!(requested_dto.issues.is_empty()); + } + + #[test] fn reducer_projects_accepted_order_agreement() { let projection = reduce( vec![accepted_decision()],