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:
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 { .. }]
+ ));
+ }
+}