lib

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

commit 9643f7cc35bc3b9f3daf85d71517a4df570d0bef
parent 68cfeaa9d58ce123802ba260bd163cd79c910f19
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Jun 2026 06:11:17 +0000

trade: add shared workflow finality reducer

Diffstat:
Mcrates/trade/src/lib.rs | 1+
Mcrates/trade/src/order.rs | 493++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mcrates/trade/src/prelude.rs | 1+
Mcrates/trade/src/validation_receipt.rs | 42++++++++++++++++++++++++++++++++++++++++++
Acrates/trade/src/workflow.rs | 677+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 1083 insertions(+), 131 deletions(-)

diff --git a/crates/trade/src/lib.rs b/crates/trade/src/lib.rs @@ -10,3 +10,4 @@ pub mod order; pub mod prelude; #[cfg(feature = "serde_json")] pub mod validation_receipt; +pub mod workflow; diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs @@ -37,6 +37,7 @@ use thiserror::Error; use crate::listing::{ RadrootsPublicListingAddress, RadrootsPublicListingAddressError, parse_public_listing_address, }; +use crate::workflow::{RadrootsTradeWorkflowState, inventory_reservations_from_commitments}; #[derive(Debug, Error)] pub enum RadrootsOrderCanonicalizationError { @@ -54,7 +55,7 @@ pub enum RadrootsOrderCanonicalizationError { MissingItems, #[error("items[{index}].bin_count must be greater than zero")] InvalidBinCount { index: usize }, - #[error("accepted decisions must contain at least one inventory commitment")] + #[error("seller accepted decisions must contain at least one inventory commitment")] MissingInventoryCommitments, #[error("inventory_commitments[{index}].bin_count must be greater than zero")] InvalidInventoryCommitmentCount { index: usize }, @@ -365,85 +366,215 @@ fn require_context_prev_event_id( } #[derive(Clone, Debug, PartialEq, Eq)] -pub enum RadrootsOrderStatus { - Missing, - Requested, - Accepted, - Declined, - Cancelled, - Invalid, -} - -#[derive(Clone, Debug, PartialEq, Eq)] pub enum RadrootsOrderIssue { MissingRequest, - MultipleRequests { event_ids: Vec<RadrootsEventId> }, - RequestPayloadInvalid { event_id: RadrootsEventId }, - RequestOrderIdMismatch { event_id: RadrootsEventId }, - RequestAuthorMismatch { event_id: RadrootsEventId }, - RequestListingAddressInvalid { event_id: RadrootsEventId }, - RequestSellerListingMismatch { event_id: RadrootsEventId }, - DecisionPayloadInvalid { event_id: RadrootsEventId }, - DecisionOrderIdMismatch { event_id: RadrootsEventId }, - DecisionAuthorMismatch { event_id: RadrootsEventId }, - DecisionCounterpartyMismatch { event_id: RadrootsEventId }, - DecisionBuyerMismatch { event_id: RadrootsEventId }, - DecisionSellerMismatch { event_id: RadrootsEventId }, - DecisionListingAddressInvalid { event_id: RadrootsEventId }, - DecisionListingMismatch { event_id: RadrootsEventId }, - DecisionRootMismatch { event_id: RadrootsEventId }, - DecisionPreviousMismatch { event_id: RadrootsEventId }, - DecisionMissingInventoryCommitments { event_id: RadrootsEventId }, - DecisionInventoryCommitmentMismatch { event_id: RadrootsEventId }, - DecisionMissingReason { event_id: RadrootsEventId }, - ConflictingDecisions { event_ids: Vec<RadrootsEventId> }, - RevisionProposalPayloadInvalid { event_id: RadrootsEventId }, - RevisionProposalOrderIdMismatch { event_id: RadrootsEventId }, - RevisionProposalAuthorMismatch { event_id: RadrootsEventId }, - RevisionProposalCounterpartyMismatch { event_id: RadrootsEventId }, - RevisionProposalBuyerMismatch { event_id: RadrootsEventId }, - RevisionProposalSellerMismatch { event_id: RadrootsEventId }, - RevisionProposalListingAddressInvalid { event_id: RadrootsEventId }, - RevisionProposalListingMismatch { event_id: RadrootsEventId }, - RevisionProposalRootMismatch { event_id: RadrootsEventId }, - RevisionProposalPreviousMismatch { event_id: RadrootsEventId }, - RevisionDecisionWithoutProposal { event_id: RadrootsEventId }, - RevisionDecisionPayloadInvalid { event_id: RadrootsEventId }, - RevisionDecisionOrderIdMismatch { event_id: RadrootsEventId }, - RevisionDecisionAuthorMismatch { event_id: RadrootsEventId }, - RevisionDecisionCounterpartyMismatch { event_id: RadrootsEventId }, - RevisionDecisionBuyerMismatch { event_id: RadrootsEventId }, - RevisionDecisionSellerMismatch { event_id: RadrootsEventId }, - RevisionDecisionListingAddressInvalid { event_id: RadrootsEventId }, - RevisionDecisionListingMismatch { event_id: RadrootsEventId }, - RevisionDecisionRootMismatch { event_id: RadrootsEventId }, - RevisionDecisionPreviousMismatch { event_id: RadrootsEventId }, - RevisionDecisionRevisionIdMismatch { event_id: RadrootsEventId }, - CancellationWithoutCancellableOrder { event_id: RadrootsEventId }, - CancellationPayloadInvalid { event_id: RadrootsEventId }, - CancellationOrderIdMismatch { event_id: RadrootsEventId }, - CancellationAuthorMismatch { event_id: RadrootsEventId }, - CancellationCounterpartyMismatch { event_id: RadrootsEventId }, - CancellationBuyerMismatch { event_id: RadrootsEventId }, - CancellationSellerMismatch { event_id: RadrootsEventId }, - CancellationListingAddressInvalid { event_id: RadrootsEventId }, - CancellationListingMismatch { event_id: RadrootsEventId }, - CancellationRootMismatch { event_id: RadrootsEventId }, - CancellationPreviousMismatch { event_id: RadrootsEventId }, - ForkedLifecycle { event_ids: Vec<RadrootsEventId> }, + MultipleRequests { + event_ids: Vec<RadrootsEventId>, + }, + RequestPayloadInvalid { + event_id: RadrootsEventId, + }, + RequestOrderIdMismatch { + event_id: RadrootsEventId, + }, + RequestAuthorMismatch { + event_id: RadrootsEventId, + }, + RequestListingAddressInvalid { + event_id: RadrootsEventId, + }, + RequestSellerListingMismatch { + event_id: RadrootsEventId, + }, + DecisionPayloadInvalid { + event_id: RadrootsEventId, + }, + DecisionOrderIdMismatch { + event_id: RadrootsEventId, + }, + DecisionAuthorMismatch { + event_id: RadrootsEventId, + }, + DecisionCounterpartyMismatch { + event_id: RadrootsEventId, + }, + DecisionBuyerMismatch { + event_id: RadrootsEventId, + }, + DecisionSellerMismatch { + event_id: RadrootsEventId, + }, + DecisionListingAddressInvalid { + event_id: RadrootsEventId, + }, + DecisionListingMismatch { + event_id: RadrootsEventId, + }, + DecisionRootMismatch { + event_id: RadrootsEventId, + }, + DecisionPreviousMismatch { + event_id: RadrootsEventId, + }, + DecisionMissingInventoryCommitments { + event_id: RadrootsEventId, + }, + DecisionInventoryCommitmentMismatch { + event_id: RadrootsEventId, + }, + DecisionMissingReason { + event_id: RadrootsEventId, + }, + ConflictingDecisions { + event_ids: Vec<RadrootsEventId>, + }, + RevisionProposalPayloadInvalid { + event_id: RadrootsEventId, + }, + RevisionProposalOrderIdMismatch { + event_id: RadrootsEventId, + }, + RevisionProposalAuthorMismatch { + event_id: RadrootsEventId, + }, + RevisionProposalCounterpartyMismatch { + event_id: RadrootsEventId, + }, + RevisionProposalBuyerMismatch { + event_id: RadrootsEventId, + }, + RevisionProposalSellerMismatch { + event_id: RadrootsEventId, + }, + RevisionProposalListingAddressInvalid { + event_id: RadrootsEventId, + }, + RevisionProposalListingMismatch { + event_id: RadrootsEventId, + }, + RevisionProposalRootMismatch { + event_id: RadrootsEventId, + }, + RevisionProposalPreviousMismatch { + event_id: RadrootsEventId, + }, + RevisionDecisionWithoutProposal { + event_id: RadrootsEventId, + }, + RevisionDecisionPayloadInvalid { + event_id: RadrootsEventId, + }, + RevisionDecisionOrderIdMismatch { + event_id: RadrootsEventId, + }, + RevisionDecisionAuthorMismatch { + event_id: RadrootsEventId, + }, + RevisionDecisionCounterpartyMismatch { + event_id: RadrootsEventId, + }, + RevisionDecisionBuyerMismatch { + event_id: RadrootsEventId, + }, + RevisionDecisionSellerMismatch { + event_id: RadrootsEventId, + }, + RevisionDecisionListingAddressInvalid { + event_id: RadrootsEventId, + }, + RevisionDecisionListingMismatch { + event_id: RadrootsEventId, + }, + RevisionDecisionRootMismatch { + event_id: RadrootsEventId, + }, + RevisionDecisionPreviousMismatch { + event_id: RadrootsEventId, + }, + RevisionDecisionRevisionIdMismatch { + event_id: RadrootsEventId, + }, + CancellationWithoutCancellableOrder { + event_id: RadrootsEventId, + }, + CancellationPayloadInvalid { + event_id: RadrootsEventId, + }, + CancellationOrderIdMismatch { + event_id: RadrootsEventId, + }, + CancellationAuthorMismatch { + event_id: RadrootsEventId, + }, + CancellationCounterpartyMismatch { + event_id: RadrootsEventId, + }, + CancellationBuyerMismatch { + event_id: RadrootsEventId, + }, + CancellationSellerMismatch { + event_id: RadrootsEventId, + }, + CancellationListingAddressInvalid { + event_id: RadrootsEventId, + }, + CancellationListingMismatch { + event_id: RadrootsEventId, + }, + CancellationRootMismatch { + event_id: RadrootsEventId, + }, + CancellationPreviousMismatch { + event_id: RadrootsEventId, + }, + ForkedLifecycle { + event_ids: Vec<RadrootsEventId>, + }, + ValidationReceiptWithoutPendingAgreement { + event_id: RadrootsEventId, + }, + ValidationReceiptOrderIdMismatch { + event_id: RadrootsEventId, + }, + ValidationReceiptTypeMismatch { + event_id: RadrootsEventId, + }, + ValidationReceiptRootMismatch { + event_id: RadrootsEventId, + }, + ValidationReceiptTargetMismatch { + event_id: RadrootsEventId, + }, + ValidationReceiptListingMismatch { + event_id: RadrootsEventId, + }, + ConflictingValidationReceipts { + event_ids: Vec<RadrootsEventId>, + }, + DeterministicValidationFailure { + event_id: RadrootsEventId, + reason: String, + }, + StaleListingEvent { + expected_event_id: RadrootsEventId, + current_event_id: RadrootsEventId, + }, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsOrderProjection { pub order_id: RadrootsOrderId, - pub status: RadrootsOrderStatus, + 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>, @@ -451,6 +582,15 @@ pub struct RadrootsOrderProjection { pub issues: Vec<RadrootsOrderIssue>, } +impl RadrootsOrderProjection { + pub(crate) fn finish_issue_state(&mut self) { + self.issues.sort_by(order_issue_sort_key); + if self.last_event_id.is_none() { + self.last_event_id = projection_issue_event_ids(&self.issues).into_iter().last(); + } + } +} + #[cfg(feature = "serde_json")] #[derive(Debug, Error)] pub enum RadrootsOrderEconomicsDigestError { @@ -475,10 +615,12 @@ pub struct RadrootsListingInventoryOrderReservation { pub struct RadrootsListingInventoryBinAccounting { pub bin_id: RadrootsInventoryBinId, pub available_count: u64, - pub accepted_reserved_count: u64, + pub pending_reserved_count: u64, + pub committed_reserved_count: u64, pub remaining_count: u64, pub over_reserved: bool, - pub accepted_orders: Vec<RadrootsListingInventoryOrderReservation>, + pub pending_orders: Vec<RadrootsListingInventoryOrderReservation>, + pub committed_orders: Vec<RadrootsListingInventoryOrderReservation>, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -607,7 +749,7 @@ where reduce_grouped_order_event_records(order_id, grouped) } -fn reduce_grouped_order_event_records( +pub(crate) fn reduce_grouped_order_event_records( order_id: &RadrootsOrderId, records: RadrootsGroupedOrderEventRecords, ) -> RadrootsOrderProjection { @@ -622,7 +764,7 @@ fn reduce_grouped_order_event_records( && revision_decisions.is_empty() && cancellations.is_empty() { - return empty_projection(order_id, RadrootsOrderStatus::Missing, false); + return empty_projection(order_id, RadrootsTradeWorkflowState::Missing, false); } let mut issues = Vec::new(); @@ -843,13 +985,13 @@ fn reduce_listing_inventory_accounting_records( }, ); match projection.status { - RadrootsOrderStatus::Accepted => { + RadrootsTradeWorkflowState::AgreedPendingRhi => { for (agreement_event_id, economics) in projection .agreement_event_id .iter() .zip(projection.economics.iter()) { - add_accepted_inventory_reservations_from_economics( + add_pending_inventory_reservations_from_economics( &mut bins, &order_id, agreement_event_id, @@ -858,9 +1000,9 @@ fn reduce_listing_inventory_accounting_records( ); } } - RadrootsOrderStatus::Cancelled => cancelled_order_ids.push(order_id), - RadrootsOrderStatus::Declined => declined_order_ids.push(order_id), - RadrootsOrderStatus::Invalid => { + RadrootsTradeWorkflowState::Cancelled => cancelled_order_ids.push(order_id), + RadrootsTradeWorkflowState::Declined => declined_order_ids.push(order_id), + RadrootsTradeWorkflowState::Invalid => { let mut event_ids = projection_issue_event_ids(&projection.issues); if event_ids.is_empty() { event_ids = fallback_order_event_ids( @@ -877,7 +1019,10 @@ fn reduce_listing_inventory_accounting_records( event_ids, }); } - RadrootsOrderStatus::Missing | RadrootsOrderStatus::Requested => {} + RadrootsTradeWorkflowState::Missing + | RadrootsTradeWorkflowState::Requested + | RadrootsTradeWorkflowState::RevisionProposed + | RadrootsTradeWorkflowState::Committed => {} } } @@ -1038,7 +1183,8 @@ fn cancelled_projection( ); } - let mut projection = request_projection(order_id, request, RadrootsOrderStatus::Cancelled); + let mut projection = + request_projection(order_id, request, RadrootsTradeWorkflowState::Cancelled); projection.cancellation_event_id = Some(cancellation.event_id.clone()); projection.lifecycle_terminal = true; projection.last_event_id = Some(cancellation.event_id.clone()); @@ -1054,7 +1200,7 @@ fn negotiation_projection( match revision_proposals.len() { 0 => { if revision_decisions.is_empty() { - request_projection(order_id, request, RadrootsOrderStatus::Requested) + request_projection(order_id, request, RadrootsTradeWorkflowState::Requested) } else { invalid_projection( order_id, @@ -1083,8 +1229,11 @@ fn negotiation_projection( } match revision_decisions.len() { 0 => { - let mut projection = - request_projection(order_id, request, RadrootsOrderStatus::Requested); + let mut projection = request_projection( + order_id, + request, + RadrootsTradeWorkflowState::RevisionProposed, + ); projection.pending_revision_event_id = Some(proposal.event_id.clone()); projection.economics = Some(proposal.payload.economics.clone()); projection.last_event_id = Some(proposal.event_id.clone()); @@ -1155,18 +1304,22 @@ fn decided_projection( match &decision.payload.decision { RadrootsOrderDecisionOutcome::Accepted { .. } => { - let mut projection = - request_projection(order_id, request, RadrootsOrderStatus::Accepted); + let mut projection = request_projection( + order_id, + request, + RadrootsTradeWorkflowState::AgreedPendingRhi, + ); projection.decision_event_id = Some(decision.event_id.clone()); - projection.lifecycle_terminal = true; projection.economics = Some(request.payload.economics.clone()); projection.agreement_event_id = Some(decision.event_id.clone()); + projection.pending_inventory_reservations = + inventory_commitments_from_items(&request.payload.items); projection.last_event_id = Some(decision.event_id.clone()); projection } RadrootsOrderDecisionOutcome::Declined { .. } => { let mut projection = - request_projection(order_id, request, RadrootsOrderStatus::Declined); + request_projection(order_id, request, RadrootsTradeWorkflowState::Declined); projection.decision_event_id = Some(decision.event_id.clone()); projection.lifecycle_terminal = true; projection.last_event_id = Some(decision.event_id.clone()); @@ -1202,17 +1355,22 @@ fn revision_decision_projection( match &decision.payload.decision { RadrootsOrderRevisionOutcome::Accepted => { - let mut projection = - request_projection(order_id, request, RadrootsOrderStatus::Accepted); + let mut projection = request_projection( + order_id, + request, + RadrootsTradeWorkflowState::AgreedPendingRhi, + ); projection.economics = Some(proposal.payload.economics.clone()); projection.agreement_event_id = Some(decision.event_id.clone()); - projection.lifecycle_terminal = true; + projection.pending_revision_event_id = Some(proposal.event_id.clone()); + projection.pending_inventory_reservations = + inventory_commitments_from_items(&proposal.payload.items); projection.last_event_id = Some(decision.event_id.clone()); projection } RadrootsOrderRevisionOutcome::Declined { .. } => { let mut projection = - request_projection(order_id, request, RadrootsOrderStatus::Declined); + request_projection(order_id, request, RadrootsTradeWorkflowState::Declined); projection.lifecycle_terminal = true; projection.pending_revision_event_id = Some(proposal.event_id.clone()); projection.last_event_id = Some(decision.event_id.clone()); @@ -1224,7 +1382,7 @@ fn revision_decision_projection( fn request_projection( order_id: &RadrootsOrderId, request: &RadrootsOrderRequestRecord, - status: RadrootsOrderStatus, + status: RadrootsTradeWorkflowState, ) -> RadrootsOrderProjection { RadrootsOrderProjection { order_id: order_id.clone(), @@ -1232,10 +1390,13 @@ fn request_projection( request_event_id: Some(request.event_id.clone()), decision_event_id: None, cancellation_event_id: None, + validation_receipt_event_id: None, lifecycle_terminal: false, economics: Some(request.payload.economics.clone()), agreement_event_id: None, pending_revision_event_id: None, + pending_inventory_reservations: Vec::new(), + committed_inventory_reservations: Vec::new(), listing_addr: Some(request.payload.listing_addr.clone()), buyer_pubkey: Some(request.payload.buyer_pubkey.clone()), seller_pubkey: Some(request.payload.seller_pubkey.clone()), @@ -1254,14 +1415,15 @@ fn invalid_projection( match request { Some(request) => { let mut projection = - request_projection(order_id, request, RadrootsOrderStatus::Invalid); + request_projection(order_id, request, RadrootsTradeWorkflowState::Invalid); projection.lifecycle_terminal = true; projection.last_event_id = last_event_id.or_else(|| Some(request.event_id.clone())); projection.issues = issues; projection } None => { - let mut projection = empty_projection(order_id, RadrootsOrderStatus::Invalid, true); + let mut projection = + empty_projection(order_id, RadrootsTradeWorkflowState::Invalid, true); projection.last_event_id = last_event_id; projection.issues = issues; projection @@ -1271,7 +1433,7 @@ fn invalid_projection( fn empty_projection( order_id: &RadrootsOrderId, - status: RadrootsOrderStatus, + status: RadrootsTradeWorkflowState, lifecycle_terminal: bool, ) -> RadrootsOrderProjection { RadrootsOrderProjection { @@ -1280,10 +1442,13 @@ fn empty_projection( request_event_id: None, decision_event_id: None, cancellation_event_id: None, + validation_receipt_event_id: None, lifecycle_terminal, economics: None, agreement_event_id: None, pending_revision_event_id: None, + pending_inventory_reservations: Vec::new(), + committed_inventory_reservations: Vec::new(), listing_addr: None, buyer_pubkey: None, seller_pubkey: None, @@ -1769,10 +1934,12 @@ where normalized.push(RadrootsListingInventoryBinAccounting { bin_id, available_count: bin.available_count, - accepted_reserved_count: 0, + pending_reserved_count: 0, + committed_reserved_count: 0, remaining_count: bin.available_count, over_reserved: false, - accepted_orders: Vec::new(), + pending_orders: Vec::new(), + committed_orders: Vec::new(), }); } } @@ -1817,7 +1984,7 @@ fn listing_order_ids( order_ids } -fn add_accepted_inventory_reservations_from_economics( +fn add_pending_inventory_reservations_from_economics( bins: &mut [RadrootsListingInventoryBinAccounting], order_id: &RadrootsOrderId, agreement_event_id: &RadrootsEventId, @@ -1851,9 +2018,9 @@ fn add_inventory_reservation_event( bin_count: u64, issues: &mut Vec<RadrootsListingInventoryAccountingIssue>, ) { - if let Some(next_count) = bin.accepted_reserved_count.checked_add(bin_count) { - bin.accepted_reserved_count = next_count; - bin.accepted_orders + if let Some(next_count) = bin.pending_reserved_count.checked_add(bin_count) { + bin.pending_reserved_count = next_count; + bin.pending_orders .push(RadrootsListingInventoryOrderReservation { order_id: order_id.clone(), agreement_event_id: event_id.clone(), @@ -1874,26 +2041,33 @@ fn finish_inventory_accounting_bins( issues: &mut Vec<RadrootsListingInventoryAccountingIssue>, ) { for bin in bins.iter_mut() { - bin.accepted_orders.sort_by(|left, right| { + bin.pending_orders.sort_by(|left, right| { + left.order_id + .cmp(&right.order_id) + .then_with(|| left.agreement_event_id.cmp(&right.agreement_event_id)) + }); + bin.committed_orders.sort_by(|left, right| { left.order_id .cmp(&right.order_id) .then_with(|| left.agreement_event_id.cmp(&right.agreement_event_id)) }); - bin.remaining_count = bin - .available_count - .saturating_sub(bin.accepted_reserved_count); - bin.over_reserved = bin.accepted_reserved_count > bin.available_count; + let reserved_count = bin + .pending_reserved_count + .saturating_add(bin.committed_reserved_count); + bin.remaining_count = bin.available_count.saturating_sub(reserved_count); + bin.over_reserved = reserved_count > bin.available_count; if bin.over_reserved { let mut event_ids = bin - .accepted_orders + .pending_orders .iter() + .chain(bin.committed_orders.iter()) .map(|reservation| reservation.agreement_event_id.clone()) .collect::<Vec<_>>(); sort_and_dedup_values(&mut event_ids); issues.push(RadrootsListingInventoryAccountingIssue::OverReserved { bin_id: bin.bin_id.clone(), available_count: bin.available_count, - reserved_count: bin.accepted_reserved_count, + reserved_count, event_ids, }); } @@ -1908,7 +2082,8 @@ fn projection_issue_event_ids(issues: &[RadrootsOrderIssue]) -> Vec<RadrootsEven RadrootsOrderIssue::MissingRequest => {} RadrootsOrderIssue::MultipleRequests { event_ids: ids } | RadrootsOrderIssue::ConflictingDecisions { event_ids: ids } - | RadrootsOrderIssue::ForkedLifecycle { event_ids: ids } => { + | RadrootsOrderIssue::ForkedLifecycle { event_ids: ids } + | RadrootsOrderIssue::ConflictingValidationReceipts { event_ids: ids } => { event_ids.extend(ids.iter().cloned()); } RadrootsOrderIssue::RequestPayloadInvalid { event_id } @@ -1961,9 +2136,23 @@ fn projection_issue_event_ids(issues: &[RadrootsOrderIssue]) -> Vec<RadrootsEven | RadrootsOrderIssue::CancellationListingAddressInvalid { event_id } | RadrootsOrderIssue::CancellationListingMismatch { event_id } | RadrootsOrderIssue::CancellationRootMismatch { event_id } - | RadrootsOrderIssue::CancellationPreviousMismatch { event_id } => { + | RadrootsOrderIssue::CancellationPreviousMismatch { event_id } + | RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { event_id } + | RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { event_id } + | RadrootsOrderIssue::ValidationReceiptTypeMismatch { event_id } + | RadrootsOrderIssue::ValidationReceiptRootMismatch { event_id } + | RadrootsOrderIssue::ValidationReceiptTargetMismatch { event_id } + | RadrootsOrderIssue::ValidationReceiptListingMismatch { event_id } + | RadrootsOrderIssue::DeterministicValidationFailure { event_id, .. } => { event_ids.push(event_id.clone()); } + RadrootsOrderIssue::StaleListingEvent { + expected_event_id, + current_event_id, + } => { + event_ids.push(expected_event_id.clone()); + event_ids.push(current_event_id.clone()); + } } } sort_and_dedup_values(&mut event_ids); @@ -2052,6 +2241,19 @@ fn inventory_commitments_match_request( }) } +fn inventory_commitments_from_items( + items: &[RadrootsOrderItem], +) -> Vec<RadrootsOrderInventoryCommitment> { + let commitments = items + .iter() + .map(|item| RadrootsOrderInventoryCommitment { + bin_id: item.bin_id.clone(), + bin_count: item.bin_count, + }) + .collect::<Vec<_>>(); + inventory_reservations_from_commitments(&commitments) +} + fn sort_and_dedup_values<T: Ord>(values: &mut Vec<T>) { values.sort(); values.dedup(); @@ -2165,6 +2367,15 @@ fn order_issue_rank(issue: &RadrootsOrderIssue) -> u8 { RadrootsOrderIssue::CancellationRootMismatch { .. } => 52, RadrootsOrderIssue::CancellationPreviousMismatch { .. } => 53, RadrootsOrderIssue::ForkedLifecycle { .. } => 54, + RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { .. } => 55, + RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { .. } => 56, + RadrootsOrderIssue::ValidationReceiptTypeMismatch { .. } => 57, + RadrootsOrderIssue::ValidationReceiptRootMismatch { .. } => 58, + RadrootsOrderIssue::ValidationReceiptTargetMismatch { .. } => 59, + RadrootsOrderIssue::ValidationReceiptListingMismatch { .. } => 60, + RadrootsOrderIssue::ConflictingValidationReceipts { .. } => 61, + RadrootsOrderIssue::DeterministicValidationFailure { .. } => 62, + RadrootsOrderIssue::StaleListingEvent { .. } => 63, } } @@ -2177,8 +2388,8 @@ mod tests { RadrootsOrderDecisionRecord, RadrootsOrderEventRecord, RadrootsOrderIssue, RadrootsOrderReductionInputs, RadrootsOrderRequestRecord, RadrootsOrderRevisionDecisionRecord, RadrootsOrderRevisionProposalRecord, - RadrootsOrderStatus, reduce_listing_inventory_accounting, reduce_order_event_records, - reduce_order_events, + RadrootsTradeWorkflowState, reduce_listing_inventory_accounting, + reduce_order_event_records, reduce_order_events, }; use core::mem::discriminant; use radroots_core::{ @@ -2746,7 +2957,7 @@ mod tests { ], ); - assert_eq!(projection.status, RadrootsOrderStatus::Invalid); + assert_eq!(projection.status, RadrootsTradeWorkflowState::Invalid); assert_order_issue_kind( &projection.issues, RadrootsOrderIssue::ForkedLifecycle { @@ -3410,10 +3621,12 @@ mod tests { let mut overflow_bin = super::RadrootsListingInventoryBinAccounting { bin_id: bin_id("bin-overflow"), available_count: u64::MAX, - accepted_reserved_count: u64::MAX, + pending_reserved_count: u64::MAX, + committed_reserved_count: 0, remaining_count: u64::MAX, over_reserved: false, - accepted_orders: Vec::new(), + pending_orders: Vec::new(), + committed_orders: Vec::new(), }; let mut overflow_issues = Vec::new(); super::add_inventory_reservation_event( @@ -3434,10 +3647,11 @@ mod tests { let mut sorting_bin = super::RadrootsListingInventoryBinAccounting { bin_id: bin_id("bin-sort"), available_count: 1, - accepted_reserved_count: 2, + pending_reserved_count: 2, + committed_reserved_count: 0, remaining_count: 1, over_reserved: false, - accepted_orders: vec![ + pending_orders: vec![ super::RadrootsListingInventoryOrderReservation { order_id: order_id("order-2"), agreement_event_id: event_id(92), @@ -3454,6 +3668,7 @@ mod tests { bin_count: 1, }, ], + committed_orders: Vec::new(), }; let mut finish_issues = Vec::new(); super::finish_inventory_accounting_bins( @@ -3462,9 +3677,9 @@ mod tests { ); assert_eq!(sorting_bin.remaining_count, 0); assert!(sorting_bin.over_reserved); - assert_eq!(sorting_bin.accepted_orders[0].order_id, order_id("order-1")); + assert_eq!(sorting_bin.pending_orders[0].order_id, order_id("order-1")); assert_eq!( - sorting_bin.accepted_orders[0].agreement_event_id, + sorting_bin.pending_orders[0].agreement_event_id, event_id(90) ); assert_inventory_issue_kind( @@ -3518,7 +3733,7 @@ mod tests { cancellations: Vec::<RadrootsOrderCancellationRecord>::new(), }, ); - assert_eq!(missing.status, RadrootsOrderStatus::Missing); + assert_eq!(missing.status, RadrootsTradeWorkflowState::Missing); let missing_request = reduce_order_events( &order_id("order-1"), @@ -3644,7 +3859,10 @@ mod tests { Vec::new(), Vec::new(), ); - assert_eq!(pending_revision.status, RadrootsOrderStatus::Requested); + assert_eq!( + pending_revision.status, + RadrootsTradeWorkflowState::RevisionProposed + ); assert_eq!( pending_revision.pending_revision_event_id, Some(event_id(3)) @@ -3711,7 +3929,7 @@ mod tests { vec![declined_revision], Vec::new(), ); - assert_eq!(declined.status, RadrootsOrderStatus::Declined); + assert_eq!(declined.status, RadrootsTradeWorkflowState::Declined); assert_eq!(declined.pending_revision_event_id, Some(event_id(3))); let cancellation_after_decision = reduce( @@ -4352,7 +4570,7 @@ mod tests { let projection = reduce(Vec::new(), Vec::new(), Vec::new(), Vec::new()); assert_eq!(projection.issues, Vec::new()); - assert_eq!(projection.status, RadrootsOrderStatus::Requested); + assert_eq!(projection.status, RadrootsTradeWorkflowState::Requested); assert_eq!(projection.request_event_id, Some(event_id(1))); assert!(!projection.lifecycle_terminal); assert!(projection.agreement_event_id.is_none()); @@ -4367,10 +4585,15 @@ mod tests { Vec::new(), ); - assert_eq!(projection.status, RadrootsOrderStatus::Accepted); + assert_eq!( + projection.status, + RadrootsTradeWorkflowState::AgreedPendingRhi + ); assert_eq!(projection.decision_event_id, Some(event_id(2))); assert_eq!(projection.agreement_event_id, Some(event_id(2))); - assert!(projection.lifecycle_terminal); + assert!(!projection.lifecycle_terminal); + assert_eq!(projection.pending_inventory_reservations.len(), 1); + assert!(projection.committed_inventory_reservations.is_empty()); } #[test] @@ -4382,7 +4605,7 @@ mod tests { Vec::new(), ); - assert_eq!(projection.status, RadrootsOrderStatus::Declined); + assert_eq!(projection.status, RadrootsTradeWorkflowState::Declined); assert_eq!(projection.decision_event_id, Some(event_id(2))); assert!(projection.lifecycle_terminal); } @@ -4396,13 +4619,18 @@ mod tests { Vec::new(), ); - assert_eq!(projection.status, RadrootsOrderStatus::Accepted); + assert_eq!( + projection.status, + RadrootsTradeWorkflowState::AgreedPendingRhi + ); assert_eq!(projection.agreement_event_id, Some(event_id(4))); assert_eq!( projection.economics.expect("economics").items[0].bin_count, 1 ); - assert!(projection.lifecycle_terminal); + assert!(!projection.lifecycle_terminal); + assert_eq!(projection.pending_inventory_reservations.len(), 1); + assert!(projection.committed_inventory_reservations.is_empty()); } #[test] @@ -4414,7 +4642,7 @@ mod tests { vec![cancellation(event_id(1))], ); - assert_eq!(projection.status, RadrootsOrderStatus::Cancelled); + assert_eq!(projection.status, RadrootsTradeWorkflowState::Cancelled); assert_eq!(projection.cancellation_event_id, Some(event_id(5))); assert!(projection.lifecycle_terminal); } @@ -4428,7 +4656,7 @@ mod tests { vec![cancellation(event_id(2))], ); - assert_eq!(projection.status, RadrootsOrderStatus::Invalid); + assert_eq!(projection.status, RadrootsTradeWorkflowState::Invalid); assert!(projection.lifecycle_terminal); } @@ -4442,7 +4670,10 @@ mod tests { ], ); - assert_eq!(projection.status, RadrootsOrderStatus::Accepted); + assert_eq!( + projection.status, + RadrootsTradeWorkflowState::AgreedPendingRhi + ); assert_eq!(projection.agreement_event_id, Some(event_id(2))); } @@ -4464,7 +4695,7 @@ mod tests { }, ); - assert_eq!(requested_projection.bins[0].accepted_reserved_count, 0); + assert_eq!(requested_projection.bins[0].pending_reserved_count, 0); assert_eq!(requested_projection.bins[0].remaining_count, 3); let projection = reduce_listing_inventory_accounting( @@ -4483,10 +4714,10 @@ mod tests { }, ); - assert_eq!(projection.bins[0].accepted_reserved_count, 2); + assert_eq!(projection.bins[0].pending_reserved_count, 2); assert_eq!(projection.bins[0].remaining_count, 1); assert_eq!( - projection.bins[0].accepted_orders[0].agreement_event_id, + projection.bins[0].pending_orders[0].agreement_event_id, event_id(2) ); } diff --git a/crates/trade/src/prelude.rs b/crates/trade/src/prelude.rs @@ -2,3 +2,4 @@ pub use crate::listing::*; pub use crate::order::*; #[cfg(feature = "serde_json")] pub use crate::validation_receipt::*; +pub use crate::workflow::*; diff --git a/crates/trade/src/validation_receipt.rs b/crates/trade/src/validation_receipt.rs @@ -165,6 +165,8 @@ pub struct RadrootsValidationReceiptExpectedBinding<'a> { pub proof_system: Option<RadrootsValidationReceiptProofSystem>, pub public_values_hash: Option<&'a str>, pub reducer_output_root: Option<&'a str>, + pub root_event_id: Option<&'a str>, + pub target_event_id: Option<&'a str>, pub verifying_key_hash: Option<&'a str>, } @@ -495,6 +497,20 @@ fn validate_expected_binding( "listing_event_id", )); } + if let Some(root_event_id) = expected.root_event_id + && tags.root_event_id != root_event_id + { + return Err(RadrootsValidationReceiptError::ExpectedBindingMismatch( + "root_event_id", + )); + } + if let Some(target_event_id) = expected.target_event_id + && tags.target_event_id != target_event_id + { + return Err(RadrootsValidationReceiptError::ExpectedBindingMismatch( + "target_event_id", + )); + } if let Some(event_set_root) = expected.event_set_root && tags.event_set_root != event_set_root { @@ -1260,6 +1276,8 @@ mod tests { br#"{"schema_version":1}"#, )), reducer_output_root: Some(&hash32('4')), + root_event_id: Some(&event_id('1')), + target_event_id: Some(&event_id('2')), ..RadrootsValidationReceiptExpectedBinding::default() }, ) @@ -1281,6 +1299,30 @@ mod tests { verify_validation_receipt_event( &event, RadrootsValidationReceiptExpectedBinding { + root_event_id: Some(&event_id('3')), + ..RadrootsValidationReceiptExpectedBinding::default() + }, + ), + Err(RadrootsValidationReceiptError::ExpectedBindingMismatch( + "root_event_id" + )) + ); + assert_eq!( + verify_validation_receipt_event( + &event, + RadrootsValidationReceiptExpectedBinding { + target_event_id: Some(&event_id('3')), + ..RadrootsValidationReceiptExpectedBinding::default() + }, + ), + Err(RadrootsValidationReceiptError::ExpectedBindingMismatch( + "target_event_id" + )) + ); + assert_eq!( + verify_validation_receipt_event( + &event, + RadrootsValidationReceiptExpectedBinding { event_set_root: Some(&hash32('d')), ..RadrootsValidationReceiptExpectedBinding::default() }, diff --git a/crates/trade/src/workflow.rs b/crates/trade/src/workflow.rs @@ -0,0 +1,677 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use radroots_events::ids::{RadrootsEventId, RadrootsOrderId}; +use radroots_events::order::RadrootsOrderInventoryCommitment; + +use crate::order::{ + RadrootsGroupedOrderEventRecords, RadrootsOrderIssue, RadrootsOrderProjection, + reduce_grouped_order_event_records, +}; + +#[cfg(feature = "serde_json")] +use crate::validation_receipt::{ + RadrootsTradeValidationReceipt, RadrootsValidationReceiptResult, RadrootsValidationReceiptTags, + RadrootsValidationReceiptType, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsTradeWorkflowState { + Missing, + Requested, + RevisionProposed, + AgreedPendingRhi, + Committed, + Declined, + Cancelled, + Invalid, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RadrootsTradeWorkflowRecords { + pub order_events: RadrootsGroupedOrderEventRecords, + #[cfg(feature = "serde_json")] + pub validation_receipts: Vec<RadrootsTradeWorkflowValidationReceiptRecord>, + pub deterministic_failures: Vec<RadrootsTradeWorkflowDeterministicFailure>, + pub expected_listing_event_id: Option<RadrootsEventId>, + pub current_listing_event_id: Option<RadrootsEventId>, +} + +#[cfg(feature = "serde_json")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeWorkflowValidationReceiptRecord { + pub event_id: RadrootsEventId, + pub order_id: RadrootsOrderId, + pub receipt: RadrootsTradeValidationReceipt, + pub tags: RadrootsValidationReceiptTags, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeWorkflowDeterministicFailure { + pub event_id: RadrootsEventId, + pub reason: String, +} + +pub fn reduce_trade_workflow_records( + order_id: &RadrootsOrderId, + records: RadrootsTradeWorkflowRecords, +) -> RadrootsOrderProjection { + let mut projection = reduce_grouped_order_event_records(order_id, records.order_events); + + if let (Some(expected), Some(current)) = ( + records.expected_listing_event_id.as_ref(), + records.current_listing_event_id.as_ref(), + ) && expected != current + { + projection.status = RadrootsTradeWorkflowState::Invalid; + projection.lifecycle_terminal = true; + projection + .issues + .push(RadrootsOrderIssue::StaleListingEvent { + expected_event_id: expected.clone(), + current_event_id: current.clone(), + }); + projection.finish_issue_state(); + return projection; + } + + if !records.deterministic_failures.is_empty() { + projection.status = RadrootsTradeWorkflowState::Invalid; + projection.lifecycle_terminal = true; + projection.pending_inventory_reservations.clear(); + projection + .issues + .extend(records.deterministic_failures.into_iter().map(|failure| { + RadrootsOrderIssue::DeterministicValidationFailure { + event_id: failure.event_id, + reason: failure.reason, + } + })); + projection.finish_issue_state(); + return projection; + } + + #[cfg(feature = "serde_json")] + { + apply_validation_receipts( + order_id, + &mut projection, + records.expected_listing_event_id.as_ref(), + records.validation_receipts, + ); + } + + projection +} + +#[cfg(feature = "serde_json")] +fn apply_validation_receipts( + order_id: &RadrootsOrderId, + projection: &mut RadrootsOrderProjection, + expected_listing_event_id: Option<&RadrootsEventId>, + validation_receipts: Vec<RadrootsTradeWorkflowValidationReceiptRecord>, +) { + if validation_receipts.is_empty() { + return; + } + + let mut valid_receipts = Vec::new(); + for receipt in validation_receipts { + if validate_receipt_binding(order_id, projection, expected_listing_event_id, &receipt) { + valid_receipts.push(receipt); + } + } + + if !projection.issues.is_empty() { + projection.status = RadrootsTradeWorkflowState::Invalid; + projection.lifecycle_terminal = true; + projection.pending_inventory_reservations.clear(); + projection.finish_issue_state(); + return; + } + + if valid_receipts.len() > 1 { + let mut event_ids = valid_receipts + .iter() + .map(|receipt| receipt.event_id.clone()) + .collect::<Vec<_>>(); + event_ids.sort(); + event_ids.dedup(); + projection.status = RadrootsTradeWorkflowState::Invalid; + projection.lifecycle_terminal = true; + projection.pending_inventory_reservations.clear(); + projection + .issues + .push(RadrootsOrderIssue::ConflictingValidationReceipts { event_ids }); + projection.finish_issue_state(); + return; + } + + let Some(receipt) = valid_receipts.first() else { + return; + }; + + match receipt.receipt.result { + RadrootsValidationReceiptResult::Valid => { + projection.status = RadrootsTradeWorkflowState::Committed; + projection.lifecycle_terminal = true; + projection.validation_receipt_event_id = Some(receipt.event_id.clone()); + projection.committed_inventory_reservations = + projection.pending_inventory_reservations.clone(); + projection.pending_inventory_reservations.clear(); + projection.last_event_id = Some(receipt.event_id.clone()); + } + RadrootsValidationReceiptResult::Invalid => { + projection.status = RadrootsTradeWorkflowState::Invalid; + projection.lifecycle_terminal = true; + projection.validation_receipt_event_id = Some(receipt.event_id.clone()); + projection.pending_inventory_reservations.clear(); + projection.last_event_id = Some(receipt.event_id.clone()); + } + } +} + +#[cfg(feature = "serde_json")] +fn validate_receipt_binding( + order_id: &RadrootsOrderId, + projection: &mut RadrootsOrderProjection, + expected_listing_event_id: Option<&RadrootsEventId>, + receipt: &RadrootsTradeWorkflowValidationReceiptRecord, +) -> bool { + let mut valid = true; + if projection.status != RadrootsTradeWorkflowState::AgreedPendingRhi { + projection.issues.push( + RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { + event_id: receipt.event_id.clone(), + }, + ); + valid = false; + } + if &receipt.order_id != order_id || receipt.tags.order_id != order_id.as_str() { + projection + .issues + .push(RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { + event_id: receipt.event_id.clone(), + }); + valid = false; + } + if receipt.receipt.receipt_type != RadrootsValidationReceiptType::TradeTransition + || receipt.tags.receipt_type != RadrootsValidationReceiptType::TradeTransition + { + projection + .issues + .push(RadrootsOrderIssue::ValidationReceiptTypeMismatch { + event_id: receipt.event_id.clone(), + }); + valid = false; + } + if projection + .request_event_id + .as_ref() + .is_none_or(|root| receipt.tags.root_event_id != root.as_str()) + { + projection + .issues + .push(RadrootsOrderIssue::ValidationReceiptRootMismatch { + event_id: receipt.event_id.clone(), + }); + valid = false; + } + if projection + .agreement_event_id + .as_ref() + .is_none_or(|target| receipt.tags.target_event_id != target.as_str()) + { + projection + .issues + .push(RadrootsOrderIssue::ValidationReceiptTargetMismatch { + event_id: receipt.event_id.clone(), + }); + valid = false; + } + if let Some(listing_event_id) = expected_listing_event_id + && receipt.tags.listing_event_id != listing_event_id.as_str() + { + projection + .issues + .push(RadrootsOrderIssue::ValidationReceiptListingMismatch { + event_id: receipt.event_id.clone(), + }); + valid = false; + } + valid +} + +pub fn inventory_reservations_from_commitments( + commitments: &[RadrootsOrderInventoryCommitment], +) -> Vec<RadrootsOrderInventoryCommitment> { + let mut reservations = commitments.to_vec(); + reservations.sort_by(|left, right| left.bin_id.cmp(&right.bin_id)); + reservations +} + +#[cfg(test)] +mod tests { + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, + }; + use radroots_events::{ + ids::{ + RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, + RadrootsOrderQuoteId, RadrootsOrderRevisionId, RadrootsPublicKey, + }, + kinds::KIND_LISTING, + order::{ + RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, + RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, + RadrootsOrderItem, RadrootsOrderPricingBasis, RadrootsOrderRequest, + RadrootsOrderRevisionDecision, RadrootsOrderRevisionOutcome, + RadrootsOrderRevisionProposal, + }, + }; + + use crate::order::{ + RadrootsGroupedOrderEventRecords, RadrootsOrderCancellationRecord, + RadrootsOrderDecisionRecord, RadrootsOrderIssue, RadrootsOrderRequestRecord, + RadrootsOrderRevisionDecisionRecord, RadrootsOrderRevisionProposalRecord, + }; + use crate::validation_receipt::{ + RadrootsTradeValidationReceipt, RadrootsValidationReceiptProof, + RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult, + RadrootsValidationReceiptStatement, RadrootsValidationReceiptType, + validation_receipt_public_values_hash_hex, validation_receipt_tags, + validation_receipt_tags_from_tags, + }; + + use super::{ + RadrootsTradeWorkflowDeterministicFailure, RadrootsTradeWorkflowRecords, + RadrootsTradeWorkflowState, RadrootsTradeWorkflowValidationReceiptRecord, + reduce_trade_workflow_records, + }; + + const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn event_id(raw: u8) -> RadrootsEventId { + RadrootsEventId::parse(format!("{raw:064x}")).expect("event id") + } + + fn public_key(raw: &str) -> RadrootsPublicKey { + RadrootsPublicKey::parse(raw).expect("public key") + } + + fn order_id() -> RadrootsOrderId { + RadrootsOrderId::parse("order-1").expect("order id") + } + + fn revision_id() -> RadrootsOrderRevisionId { + RadrootsOrderRevisionId::parse("revision-1").expect("revision id") + } + + fn quote_id(raw: &str) -> RadrootsOrderQuoteId { + RadrootsOrderQuoteId::parse(raw).expect("quote id") + } + + fn bin_id(raw: &str) -> RadrootsInventoryBinId { + RadrootsInventoryBinId::parse(raw).expect("bin id") + } + + fn listing_addr() -> RadrootsListingAddress { + RadrootsListingAddress::parse(format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")) + .expect("listing address") + } + + fn economics(bin_count: u32) -> RadrootsOrderEconomics { + let currency = RadrootsCoreCurrency::USD; + RadrootsOrderEconomics { + quote_id: quote_id("quote-1"), + quote_version: 1, + pricing_basis: RadrootsOrderPricingBasis::ListingEvent, + currency, + items: vec![RadrootsOrderEconomicItem { + bin_id: bin_id("bin-1"), + bin_count, + quantity_amount: RadrootsCoreDecimal::ONE, + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: RadrootsCoreDecimal::from(1200u32), + unit_price_currency: currency, + line_subtotal: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(u64::from(bin_count) * 1200), + currency, + ), + }], + discounts: Vec::new(), + adjustments: Vec::new(), + subtotal: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(u64::from(bin_count) * 1200), + currency, + ), + discount_total: RadrootsCoreMoney::zero(currency), + adjustment_total: RadrootsCoreMoney::zero(currency), + total: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(u64::from(bin_count) * 1200), + currency, + ), + } + } + + fn request_record() -> RadrootsOrderRequestRecord { + RadrootsOrderRequestRecord { + event_id: event_id(1), + author_pubkey: public_key(BUYER), + payload: RadrootsOrderRequest { + order_id: order_id(), + listing_addr: listing_addr(), + buyer_pubkey: public_key(BUYER), + seller_pubkey: public_key(SELLER), + items: vec![RadrootsOrderItem { + bin_id: bin_id("bin-1"), + bin_count: 2, + }], + economics: economics(2), + }, + } + } + + fn accepted_decision() -> RadrootsOrderDecisionRecord { + RadrootsOrderDecisionRecord { + event_id: event_id(2), + author_pubkey: public_key(SELLER), + counterparty_pubkey: public_key(BUYER), + root_event_id: event_id(1), + prev_event_id: event_id(1), + payload: RadrootsOrderDecision { + order_id: order_id(), + listing_addr: listing_addr(), + buyer_pubkey: public_key(BUYER), + seller_pubkey: public_key(SELLER), + decision: RadrootsOrderDecisionOutcome::Accepted { + inventory_commitments: vec![RadrootsOrderInventoryCommitment { + bin_id: bin_id("bin-1"), + bin_count: 2, + }], + }, + }, + } + } + + fn revision_proposal() -> RadrootsOrderRevisionProposalRecord { + RadrootsOrderRevisionProposalRecord { + event_id: event_id(3), + author_pubkey: public_key(SELLER), + counterparty_pubkey: public_key(BUYER), + root_event_id: event_id(1), + prev_event_id: event_id(1), + payload: RadrootsOrderRevisionProposal { + revision_id: revision_id(), + order_id: order_id(), + listing_addr: listing_addr(), + buyer_pubkey: public_key(BUYER), + seller_pubkey: public_key(SELLER), + root_event_id: event_id(1), + prev_event_id: event_id(1), + items: vec![RadrootsOrderItem { + bin_id: bin_id("bin-1"), + bin_count: 1, + }], + economics: economics(1), + reason: "one bin remains".to_string(), + }, + } + } + + fn accepted_revision_decision() -> RadrootsOrderRevisionDecisionRecord { + RadrootsOrderRevisionDecisionRecord { + event_id: event_id(4), + author_pubkey: public_key(BUYER), + counterparty_pubkey: public_key(SELLER), + root_event_id: event_id(1), + prev_event_id: event_id(3), + payload: RadrootsOrderRevisionDecision { + revision_id: revision_id(), + order_id: order_id(), + listing_addr: listing_addr(), + buyer_pubkey: public_key(BUYER), + seller_pubkey: public_key(SELLER), + root_event_id: event_id(1), + prev_event_id: event_id(3), + decision: RadrootsOrderRevisionOutcome::Accepted, + }, + } + } + + fn cancellation(prev_event_id: RadrootsEventId) -> RadrootsOrderCancellationRecord { + RadrootsOrderCancellationRecord { + event_id: event_id(5), + author_pubkey: public_key(BUYER), + counterparty_pubkey: public_key(SELLER), + root_event_id: event_id(1), + prev_event_id, + payload: RadrootsOrderCancellation { + order_id: order_id(), + listing_addr: listing_addr(), + buyer_pubkey: public_key(BUYER), + seller_pubkey: public_key(SELLER), + reason: "changed plans".to_string(), + }, + } + } + + fn workflow_records() -> RadrootsTradeWorkflowRecords { + RadrootsTradeWorkflowRecords { + order_events: RadrootsGroupedOrderEventRecords { + requests: vec![request_record()], + decisions: Vec::new(), + revision_proposals: Vec::new(), + revision_decisions: Vec::new(), + cancellations: Vec::new(), + }, + validation_receipts: Vec::new(), + deterministic_failures: Vec::new(), + expected_listing_event_id: Some(event_id(80)), + current_listing_event_id: Some(event_id(80)), + } + } + + fn receipt_record( + event_raw: u8, + result: RadrootsValidationReceiptResult, + root_event_id: RadrootsEventId, + target_event_id: RadrootsEventId, + listing_event_id: RadrootsEventId, + ) -> RadrootsTradeWorkflowValidationReceiptRecord { + let error_bitmap = match result { + RadrootsValidationReceiptResult::Valid => { + "0x00000000000000000000000000000000".to_string() + } + RadrootsValidationReceiptResult::Invalid => { + "0x00000000000000000000000000000001".to_string() + } + }; + let receipt = RadrootsTradeValidationReceipt { + changed_records_root: hash32('6'), + domain: "radroots.receipt".to_string(), + error_bitmap, + event_set_root: hash32('c'), + new_state_root: hash32('4'), + previous_state_root: hash32('3'), + proof: RadrootsValidationReceiptProof { + inline_proof_base64: None, + mode: None, + program_hash: None, + proof_reference: None, + system: RadrootsValidationReceiptProofSystem::None, + verifying_key_hash: None, + }, + public_values_hash: validation_receipt_public_values_hash_hex( + br#"{"schema_version":1}"#, + ), + receipt_type: RadrootsValidationReceiptType::TradeTransition, + result, + statement: RadrootsValidationReceiptStatement { + listing_event_id: listing_event_id.into_string(), + root_event_id: root_event_id.into_string(), + target_event_id: target_event_id.into_string(), + statement_type: RadrootsValidationReceiptType::TradeTransition, + }, + version: 1, + }; + let tags = validation_receipt_tags(order_id().as_str(), &receipt).expect("receipt tags"); + RadrootsTradeWorkflowValidationReceiptRecord { + event_id: event_id(event_raw), + order_id: order_id(), + receipt, + tags: validation_receipt_tags_from_tags(&tags).expect("parsed receipt tags"), + } + } + + fn hash32(c: char) -> String { + format!("0x{}", c.to_string().repeat(64)) + } + + #[test] + fn workflow_seller_acceptance_waits_for_rhi_receipt() { + let mut records = workflow_records(); + records.order_events.decisions.push(accepted_decision()); + + let projection = reduce_trade_workflow_records(&order_id(), records); + + assert_eq!( + projection.status, + RadrootsTradeWorkflowState::AgreedPendingRhi + ); + assert!(!projection.lifecycle_terminal); + assert_eq!(projection.agreement_event_id, Some(event_id(2))); + assert_eq!(projection.pending_inventory_reservations.len(), 1); + assert!(projection.committed_inventory_reservations.is_empty()); + } + + #[test] + fn workflow_valid_receipt_commits_pending_agreement() { + let mut records = workflow_records(); + records.order_events.decisions.push(accepted_decision()); + records.validation_receipts.push(receipt_record( + 9, + RadrootsValidationReceiptResult::Valid, + event_id(1), + event_id(2), + event_id(80), + )); + + let projection = reduce_trade_workflow_records(&order_id(), records); + + assert_eq!(projection.status, RadrootsTradeWorkflowState::Committed); + assert!(projection.lifecycle_terminal); + assert_eq!(projection.validation_receipt_event_id, Some(event_id(9))); + assert!(projection.pending_inventory_reservations.is_empty()); + assert_eq!(projection.committed_inventory_reservations.len(), 1); + } + + #[test] + fn workflow_invalid_receipt_and_deterministic_failure_invalidate_pending_agreement() { + let mut invalid_receipt = workflow_records(); + invalid_receipt + .order_events + .decisions + .push(accepted_decision()); + invalid_receipt.validation_receipts.push(receipt_record( + 9, + RadrootsValidationReceiptResult::Invalid, + event_id(1), + event_id(2), + event_id(80), + )); + + let projection = reduce_trade_workflow_records(&order_id(), invalid_receipt); + assert_eq!(projection.status, RadrootsTradeWorkflowState::Invalid); + assert_eq!(projection.validation_receipt_event_id, Some(event_id(9))); + assert!(projection.pending_inventory_reservations.is_empty()); + + let mut deterministic_failure = workflow_records(); + deterministic_failure + .order_events + .decisions + .push(accepted_decision()); + deterministic_failure.deterministic_failures.push( + RadrootsTradeWorkflowDeterministicFailure { + event_id: event_id(10), + reason: "inventory proof failed".to_string(), + }, + ); + + let projection = reduce_trade_workflow_records(&order_id(), deterministic_failure); + assert_eq!(projection.status, RadrootsTradeWorkflowState::Invalid); + assert!(matches!( + projection.issues.as_slice(), + [RadrootsOrderIssue::DeterministicValidationFailure { .. }] + )); + } + + #[test] + fn workflow_revision_acceptance_waits_for_rhi_and_cancellation_after_agreement_is_invalid() { + let mut records = workflow_records(); + records + .order_events + .revision_proposals + .push(revision_proposal()); + records + .order_events + .revision_decisions + .push(accepted_revision_decision()); + + let projection = reduce_trade_workflow_records(&order_id(), records); + assert_eq!( + projection.status, + RadrootsTradeWorkflowState::AgreedPendingRhi + ); + assert_eq!(projection.agreement_event_id, Some(event_id(4))); + assert_eq!(projection.pending_inventory_reservations[0].bin_count, 1); + + let mut cancelled = workflow_records(); + cancelled.order_events.decisions.push(accepted_decision()); + cancelled + .order_events + .cancellations + .push(cancellation(event_id(2))); + let projection = reduce_trade_workflow_records(&order_id(), cancelled); + assert_eq!(projection.status, RadrootsTradeWorkflowState::Invalid); + assert!(matches!( + projection.issues.as_slice(), + [RadrootsOrderIssue::ForkedLifecycle { .. }] + )); + } + + #[test] + fn workflow_rejects_stale_listing_and_bad_receipt_bindings() { + let mut stale = workflow_records(); + stale.order_events.decisions.push(accepted_decision()); + stale.current_listing_event_id = Some(event_id(81)); + + let projection = reduce_trade_workflow_records(&order_id(), stale); + assert_eq!(projection.status, RadrootsTradeWorkflowState::Invalid); + assert!(matches!( + projection.issues.as_slice(), + [RadrootsOrderIssue::StaleListingEvent { .. }] + )); + + let mut bad_receipt = workflow_records(); + bad_receipt.order_events.decisions.push(accepted_decision()); + bad_receipt.validation_receipts.push(receipt_record( + 9, + RadrootsValidationReceiptResult::Valid, + event_id(1), + event_id(3), + event_id(80), + )); + + let projection = reduce_trade_workflow_records(&order_id(), bad_receipt); + assert_eq!(projection.status, RadrootsTradeWorkflowState::Invalid); + assert!(matches!( + projection.issues.as_slice(), + [RadrootsOrderIssue::ValidationReceiptTargetMismatch { .. }] + )); + } +}