commit 2a3090443e763a208a84ab8e2b0ce8ca0bf323bd
parent dbb9e054196896c97c5bb69de08e2f63bf012298
Author: triesap <tyson@radroots.org>
Date: Thu, 16 Jul 2026 09:14:18 +0000
trade: add release-product reducer states
- replace exported order workflow with trade reducer projections
- add orthogonal agreement, evidence, conflict, private, and attestation states
- preserve contested claims and missing-parent evidence in reducer tests
- register reducer operation and conformance vector
Diffstat:
6 files changed, 1643 insertions(+), 831 deletions(-)
diff --git a/contracts/conformance/vectors/trade/reduce_records.v1.json b/contracts/conformance/vectors/trade/reduce_records.v1.json
@@ -0,0 +1,67 @@
+{
+ "suite": "trade",
+ "contract_version": "0.1.0",
+ "vectors": [
+ {
+ "id": "trade_reduce_agreed_projection_digest_001",
+ "kind": "trade.reduce_records",
+ "input": {
+ "shape": "proposal_plus_seller_acceptance_with_live_all_line_reservation",
+ "mutation_order": "arbitrary_with_duplicate_transport_observations",
+ "attestation_records": []
+ },
+ "expected": {
+ "negotiation_state": "open",
+ "agreement_state": "agreed",
+ "evidence_state": "complete",
+ "conflict_state": "none",
+ "attestation_state": "none",
+ "projection_digest": "input_order_independent_sha256_over_canonical_projection"
+ }
+ },
+ {
+ "id": "trade_reduce_contested_claims_002",
+ "kind": "trade.reduce_records",
+ "input": {
+ "shape": "proposal_plus_two_causally_unordered_incompatible_acceptances",
+ "winner_selection": "forbidden"
+ },
+ "expected": {
+ "agreement_state": "contested",
+ "conflict_state": "double_acceptance",
+ "contested_claim_ids": "all_incompatible_claim_mutation_ids_sorted",
+ "forbidden_winner_inputs": [
+ "timestamp",
+ "transport_event_id",
+ "relay_order",
+ "observation_order",
+ "first_seen"
+ ]
+ }
+ },
+ {
+ "id": "trade_reduce_attestation_only_003",
+ "kind": "trade.reduce_records",
+ "input": {
+ "shape": "agreed_claim_plus_invalid_attestation_record"
+ },
+ "expected": {
+ "agreement_state": "agreed",
+ "attestation_state": "present_invalid",
+ "attestation_agreement_authority": false
+ }
+ },
+ {
+ "id": "trade_reduce_missing_parent_004",
+ "kind": "trade.reduce_records",
+ "input": {
+ "shape": "known_acceptance_with_missing_direct_parent"
+ },
+ "expected": {
+ "evidence_state": "missing",
+ "agreement_claim_visibility": "retained_when_local_claim_is_known",
+ "global_invalidity": false
+ }
+ }
+ ]
+}
diff --git a/contracts/operations.toml b/contracts/operations.toml
@@ -51,6 +51,22 @@ public = [
"RadrootsSellerReservationLineV1",
"RadrootsTradeDecisionV1",
"RadrootsTradeCanonicalMutationV1",
+ "RadrootsTradeReductionInputV1",
+ "RadrootsTradeMutationRecordV1",
+ "RadrootsTradePrivateTermsEvidenceV1",
+ "RadrootsTradeAttestationResultV1",
+ "RadrootsTradeAttestationRecordV1",
+ "RadrootsTradeProjectionV1",
+ "RadrootsTradeAgreementClaimV1",
+ "RadrootsTradeReducerIssueV1",
+ "RadrootsTradeNegotiationStateV1",
+ "RadrootsTradeAgreementStateV1",
+ "RadrootsTradeEvidenceStateV1",
+ "RadrootsTradeConflictStateV1",
+ "RadrootsTradePrivateTermsStateV1",
+ "RadrootsTradeAttestationStateV1",
+ "RadrootsTradeFulfillmentStateV1",
+ "RadrootsTradePaymentStateV1",
"RadrootsWikiArticle",
"RadrootsWikiRedirect",
"RadrootsWikiMergeRequest",
@@ -388,6 +404,27 @@ rust_types = ["radroots_event::ids::RadrootsListingAddress"]
[operations.trade_parse_listing_address.conformance]
vector = "contracts/conformance/vectors/trade/parse_listing_address.v1.json"
+[operations.trade_reduce_records]
+domain = "trade"
+id = "trade.reduce_records"
+stability = "beta"
+inputs = ["RadrootsTradeReductionInputV1"]
+outputs = ["RadrootsTradeProjectionV1"]
+error_class = "validation_error"
+deterministic = true
+signing = "native"
+transport = "native"
+
+[operations.trade_reduce_records.implementation]
+rust_modules = ["crates/trade/src/workflow.rs"]
+rust_types = [
+ "radroots_trade::workflow::RadrootsTradeReductionInputV1",
+ "radroots_trade::workflow::RadrootsTradeProjectionV1",
+]
+
+[operations.trade_reduce_records.conformance]
+vector = "contracts/conformance/vectors/trade/reduce_records.v1.json"
+
[operations.trade_validation_validate_listing_event]
domain = "trade_validation"
id = "trade_validation.validate_listing_event"
diff --git a/crates/trade/src/dto.rs b/crates/trade/src/dto.rs
@@ -7,16 +7,26 @@ use crate::listing::{
model::{RadrootsTradeListingSubtotal, RadrootsTradeListingTotal},
validation::RadrootsTradeListing,
};
-use crate::order::RadrootsOrderWorkflowProjection;
-use crate::workflow::RadrootsTradeWorkflowState;
+use crate::workflow::{
+ RadrootsTradeAgreementStateV1, RadrootsTradeAttestationStateV1, RadrootsTradeConflictStateV1,
+ RadrootsTradeEvidenceStateV1, RadrootsTradeFulfillmentStateV1, RadrootsTradeNegotiationStateV1,
+ RadrootsTradePaymentStateV1, RadrootsTradePrivateTermsStateV1, RadrootsTradeProjectionV1,
+};
-pub fn dto_roots() -> [RootDescriptor; 5] {
+pub fn dto_roots() -> [RootDescriptor; 12] {
[
RootDescriptor::new::<RadrootsTradeListing>(),
RootDescriptor::new::<RadrootsTradeListingSubtotal>(),
RootDescriptor::new::<RadrootsTradeListingTotal>(),
- RootDescriptor::new::<RadrootsTradeWorkflowState>(),
- RootDescriptor::new::<RadrootsOrderWorkflowProjection>(),
+ RootDescriptor::new::<RadrootsTradeNegotiationStateV1>(),
+ RootDescriptor::new::<RadrootsTradeAgreementStateV1>(),
+ RootDescriptor::new::<RadrootsTradeEvidenceStateV1>(),
+ RootDescriptor::new::<RadrootsTradeConflictStateV1>(),
+ RootDescriptor::new::<RadrootsTradePrivateTermsStateV1>(),
+ RootDescriptor::new::<RadrootsTradeAttestationStateV1>(),
+ RootDescriptor::new::<RadrootsTradeFulfillmentStateV1>(),
+ RootDescriptor::new::<RadrootsTradePaymentStateV1>(),
+ RootDescriptor::new::<RadrootsTradeProjectionV1>(),
]
}
@@ -233,8 +243,15 @@ mod tests {
"RadrootsTradeListing",
"RadrootsTradeListingSubtotal",
"RadrootsTradeListingTotal",
- "RadrootsTradeWorkflowState",
- "RadrootsOrderWorkflowProjection",
+ "RadrootsTradeNegotiationStateV1",
+ "RadrootsTradeAgreementStateV1",
+ "RadrootsTradeEvidenceStateV1",
+ "RadrootsTradeConflictStateV1",
+ "RadrootsTradePrivateTermsStateV1",
+ "RadrootsTradeAttestationStateV1",
+ "RadrootsTradeFulfillmentStateV1",
+ "RadrootsTradePaymentStateV1",
+ "RadrootsTradeProjectionV1",
];
#[test]
@@ -277,71 +294,85 @@ mod tests {
}
#[test]
- fn trade_workflow_state_uses_approved_wire_vocabulary() {
+ fn trade_reducer_state_roots_use_approved_wire_vocabulary() {
let registry = build_registry(dto_roots());
- let workflow_state = find_enum(®istry, "RadrootsTradeWorkflowState");
+ let negotiation = find_enum(®istry, "RadrootsTradeNegotiationStateV1");
+ let agreement = find_enum(®istry, "RadrootsTradeAgreementStateV1");
+ let evidence = find_enum(®istry, "RadrootsTradeEvidenceStateV1");
+ let conflict = find_enum(®istry, "RadrootsTradeConflictStateV1");
assert_eq!(
- enum_wire_names(workflow_state),
+ enum_wire_names(negotiation),
+ ["none", "open", "closed_declined", "closed_expired"]
+ );
+ assert_eq!(
+ enum_wire_names(agreement),
+ ["none", "agreed", "contested", "cancelled"]
+ );
+ assert_eq!(
+ enum_wire_names(evidence),
[
+ "complete",
"missing",
- "requested",
- "agreed_pending_validation",
- "committed",
- "declined",
- "cancelled",
- "validation_expired",
- "invalid",
+ "query_partial",
+ "unsupported_version"
+ ]
+ );
+ assert_eq!(
+ enum_wire_names(conflict),
+ [
+ "none",
+ "concurrent_candidates",
+ "double_acceptance",
+ "decision_conflict",
+ "cancellation_conflict",
+ "invalid_causal_chain",
+ "inventory_authority_conflict",
]
);
}
#[test]
- fn trade_order_workflow_projection_models_missing_state_with_optional_evidence() {
+ fn trade_projection_exports_orthogonal_state_fields() {
let registry = build_registry(dto_roots());
- let projection = find_struct(®istry, "RadrootsOrderWorkflowProjection");
+ let projection = find_struct(®istry, "RadrootsTradeProjectionV1");
+ let fields = field_names(projection);
- assert!(field_names(projection).contains(&"status"));
- for optional_field in [
- "request_event_id",
- "decision_event_id",
- "cancellation_event_id",
- "validation_receipt_event_id",
- "economics",
- "agreement_event_id",
- "listing_addr",
- "buyer_pubkey",
- "seller_pubkey",
- "last_event_id",
+ for required_field in [
+ "negotiation_state",
+ "agreement_state",
+ "evidence_state",
+ "conflict_state",
+ "private_terms_state",
+ "attestation_state",
+ "fulfillment_state",
+ "payment_state",
+ "agreement_claims",
+ "contested_claim_ids",
+ "projection_digest",
] {
- let presence = registry
- .struct_field_presence("RadrootsOrderWorkflowProjection", optional_field)
- .expect("projection field presence");
assert!(
- !presence.required_on_deserialize,
- "{optional_field} must not be required for missing projections"
- );
- assert!(
- presence.nullable,
- "{optional_field} must be nullable for missing projections"
+ fields.contains(&required_field),
+ "{required_field} must remain on the source projection contract"
);
}
}
#[test]
- fn trade_order_workflow_projection_omits_binding_only_discount_fields() {
+ fn trade_projection_omits_order_status_and_validation_finality_fields() {
let registry = build_registry(dto_roots());
- let projection = find_struct(®istry, "RadrootsOrderWorkflowProjection");
+ let projection = find_struct(®istry, "RadrootsTradeProjectionV1");
let fields = field_names(projection);
for stale_field in [
- "root_event_id",
- "last_message_type",
- "last_discount_request",
- "last_discount_offer",
- "accepted_discount",
- "last_discount_decline_reason",
- "has_requested_discounts",
+ "status",
+ "order_id",
+ "request_event_id",
+ "decision_event_id",
+ "cancellation_event_id",
+ "validation_receipt_event_id",
+ "committed_inventory_reservations",
+ "pending_inventory_reservations",
] {
assert!(
!fields.contains(&stale_field),
diff --git a/crates/trade/src/lib.rs b/crates/trade/src/lib.rs
@@ -7,10 +7,7 @@ extern crate alloc;
pub mod dto;
pub mod identity;
pub mod listing;
-pub mod order;
pub mod prelude;
-#[cfg(feature = "event_store")]
-pub mod projection;
#[cfg(feature = "serde_json")]
pub mod validation_receipt;
pub mod workflow;
diff --git a/crates/trade/src/prelude.rs b/crates/trade/src/prelude.rs
@@ -1,8 +1,5 @@
pub use crate::identity::*;
pub use crate::listing::*;
-pub use crate::order::*;
-#[cfg(feature = "event_store")]
-pub use crate::projection::*;
#[cfg(feature = "serde_json")]
pub use crate::validation_receipt::*;
pub use crate::workflow::*;
diff --git a/crates/trade/src/workflow.rs b/crates/trade/src/workflow.rs
@@ -1,937 +1,1620 @@
+#![forbid(unsafe_code)]
+
#[cfg(not(feature = "std"))]
use alloc::{
+ collections::{BTreeMap, BTreeSet},
+ string::{String, ToString},
+ vec::Vec,
+};
+#[cfg(feature = "std")]
+use std::{
+ collections::{BTreeMap, BTreeSet},
string::{String, ToString},
vec::Vec,
};
-use radroots_event::ids::{RadrootsEventId, RadrootsOrderId};
-use radroots_event::order::RadrootsOrderInventoryCommitment;
-
-use crate::{
- identity::RadrootsTradeLocator,
- order::{
- RadrootsGroupedOrderEventRecords, RadrootsOrderIssue, RadrootsOrderProjection,
- RadrootsTradeLocatorGroupedOrderEventRecordsResolution,
- RadrootsTradeLocatorProjectionResolution, grouped_order_event_records_for_trade_locator,
- reduce_grouped_order_event_records,
+use radroots_event::{
+ ids::{
+ RadrootsDTag, RadrootsEventId, RadrootsPublicKey, RadrootsTradeCandidateId,
+ RadrootsTradeId, RadrootsTradeMutationId,
+ },
+ trade::{
+ RADROOTS_TRADE_SCHEMA_VERSION, RadrootsSellerReservationAssertionV1,
+ RadrootsTradeCandidateTermsV1, RadrootsTradeDecisionV1, RadrootsTradeMutationBodyV1,
+ RadrootsTradeMutationEnvelopeV1,
},
};
+#[cfg(feature = "serde_json")]
+use sha2::{Digest, Sha256};
+pub const RADROOTS_TRADE_REDUCER_CONTRACT_ID: &str = "radroots.trade.reducer.v1";
+pub const RADROOTS_TRADE_REDUCER_VERSION: u16 = 1;
#[cfg(feature = "serde_json")]
-use crate::validation_receipt::{
- RadrootsTradeValidationReceipt, RadrootsValidationReceiptResult, RadrootsValidationReceiptTags,
- RadrootsValidationReceiptType,
-};
+const RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN: &[u8] = b"radroots:trade-projection:v1\0";
#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
#[cfg_attr(feature = "dto-bindgen", dto(export))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum RadrootsTradeWorkflowState {
- Missing,
- Requested,
- AgreedPendingValidation,
- Committed,
- Declined,
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+pub enum RadrootsTradeNegotiationStateV1 {
+ #[default]
+ None,
+ Open,
+ ClosedDeclined,
+ ClosedExpired,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+pub enum RadrootsTradeAgreementStateV1 {
+ #[default]
+ None,
+ Agreed,
+ Contested,
Cancelled,
- ValidationExpired,
- 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_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+pub enum RadrootsTradeEvidenceStateV1 {
+ Complete,
+ #[default]
+ Missing,
+ QueryPartial,
+ UnsupportedVersion,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+pub enum RadrootsTradeConflictStateV1 {
+ #[default]
+ None,
+ ConcurrentCandidates,
+ DoubleAcceptance,
+ DecisionConflict,
+ CancellationConflict,
+ InvalidCausalChain,
+ InventoryAuthorityConflict,
}
-#[cfg(feature = "serde_json")]
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+pub enum RadrootsTradePrivateTermsStateV1 {
+ #[default]
+ NotRequired,
+ AvailableVerified,
+ Missing,
+ Undecryptable,
+ CommitmentMismatch,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+pub enum RadrootsTradeAttestationStateV1 {
+ #[default]
+ None,
+ PresentValid,
+ PresentInvalid,
+ Conflicting,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+pub enum RadrootsTradeFulfillmentStateV1 {
+ #[default]
+ NotStarted,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+pub enum RadrootsTradePaymentStateV1 {
+ #[default]
+ NotTracked,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsTradeWorkflowValidationReceiptRecord {
- pub event_id: RadrootsEventId,
- pub order_id: RadrootsOrderId,
- pub receipt: RadrootsTradeValidationReceipt,
- pub tags: RadrootsValidationReceiptTags,
+pub struct RadrootsTradeReductionInputV1 {
+ pub trade_id: RadrootsTradeId,
+ pub mutations: Vec<RadrootsTradeMutationRecordV1>,
+ pub private_terms: Vec<RadrootsTradePrivateTermsEvidenceV1>,
+ pub attestations: Vec<RadrootsTradeAttestationRecordV1>,
+ pub evidence_state: RadrootsTradeEvidenceStateV1,
+ pub observed_at_unix_s: Option<u64>,
+}
+
+impl RadrootsTradeReductionInputV1 {
+ pub fn new(trade_id: RadrootsTradeId) -> Self {
+ Self {
+ trade_id,
+ mutations: Vec::new(),
+ private_terms: Vec::new(),
+ attestations: Vec::new(),
+ evidence_state: RadrootsTradeEvidenceStateV1::Complete,
+ observed_at_unix_s: None,
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeMutationRecordV1 {
+ pub transport_event_id: Option<RadrootsEventId>,
+ pub mutation: RadrootsTradeMutationEnvelopeV1,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradePrivateTermsEvidenceV1 {
+ pub candidate_id: RadrootsTradeCandidateId,
+ pub state: RadrootsTradePrivateTermsStateV1,
}
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub enum RadrootsTradeAttestationResultV1 {
+ Valid,
+ Invalid,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsTradeWorkflowDeterministicFailure {
+pub struct RadrootsTradeAttestationRecordV1 {
pub event_id: RadrootsEventId,
- pub reason: String,
+ pub claim_mutation_id: RadrootsTradeMutationId,
+ pub result: RadrootsTradeAttestationResultV1,
}
-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);
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeProjectionV1 {
+ pub reducer_contract_id: String,
+ pub reducer_version: u16,
+ pub trade_id: RadrootsTradeId,
+ pub root_mutation_id: Option<RadrootsTradeMutationId>,
+ pub buyer_pubkey: Option<RadrootsPublicKey>,
+ pub seller_pubkey: Option<RadrootsPublicKey>,
+ pub farm_id: Option<RadrootsDTag>,
+ pub negotiation_state: RadrootsTradeNegotiationStateV1,
+ pub agreement_state: RadrootsTradeAgreementStateV1,
+ pub evidence_state: RadrootsTradeEvidenceStateV1,
+ pub conflict_state: RadrootsTradeConflictStateV1,
+ pub private_terms_state: RadrootsTradePrivateTermsStateV1,
+ pub attestation_state: RadrootsTradeAttestationStateV1,
+ pub fulfillment_state: RadrootsTradeFulfillmentStateV1,
+ pub payment_state: RadrootsTradePaymentStateV1,
+ pub candidate_heads: Vec<RadrootsTradeMutationId>,
+ pub agreement_claims: Vec<RadrootsTradeAgreementClaimV1>,
+ pub active_agreement_claim_ids: Vec<RadrootsTradeMutationId>,
+ pub contested_claim_ids: Vec<RadrootsTradeMutationId>,
+ pub cancelled_claim_ids: Vec<RadrootsTradeMutationId>,
+ pub declined_candidate_ids: Vec<RadrootsTradeCandidateId>,
+ pub missing_parent_ids: Vec<RadrootsTradeMutationId>,
+ pub missing_proposal_ids: Vec<RadrootsTradeMutationId>,
+ pub unsupported_mutation_ids: Vec<RadrootsTradeMutationId>,
+ pub issues: Vec<RadrootsTradeReducerIssueV1>,
+ pub attestations: Vec<RadrootsTradeAttestationRecordV1>,
+ pub projection_digest: String,
+}
- 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;
+impl RadrootsTradeProjectionV1 {
+ fn empty(trade_id: RadrootsTradeId) -> Self {
+ Self {
+ reducer_contract_id: RADROOTS_TRADE_REDUCER_CONTRACT_ID.to_string(),
+ reducer_version: RADROOTS_TRADE_REDUCER_VERSION,
+ trade_id,
+ root_mutation_id: None,
+ buyer_pubkey: None,
+ seller_pubkey: None,
+ farm_id: None,
+ negotiation_state: RadrootsTradeNegotiationStateV1::None,
+ agreement_state: RadrootsTradeAgreementStateV1::None,
+ evidence_state: RadrootsTradeEvidenceStateV1::Missing,
+ conflict_state: RadrootsTradeConflictStateV1::None,
+ private_terms_state: RadrootsTradePrivateTermsStateV1::NotRequired,
+ attestation_state: RadrootsTradeAttestationStateV1::None,
+ fulfillment_state: RadrootsTradeFulfillmentStateV1::NotStarted,
+ payment_state: RadrootsTradePaymentStateV1::NotTracked,
+ candidate_heads: Vec::new(),
+ agreement_claims: Vec::new(),
+ active_agreement_claim_ids: Vec::new(),
+ contested_claim_ids: Vec::new(),
+ cancelled_claim_ids: Vec::new(),
+ declined_candidate_ids: Vec::new(),
+ missing_parent_ids: Vec::new(),
+ missing_proposal_ids: Vec::new(),
+ unsupported_mutation_ids: Vec::new(),
+ issues: Vec::new(),
+ attestations: Vec::new(),
+ projection_digest: String::new(),
+ }
}
- 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;
+ fn finish(&mut self) {
+ self.candidate_heads.sort();
+ self.candidate_heads.dedup();
+ self.active_agreement_claim_ids.sort();
+ self.active_agreement_claim_ids.dedup();
+ self.contested_claim_ids.sort();
+ self.contested_claim_ids.dedup();
+ self.cancelled_claim_ids.sort();
+ self.cancelled_claim_ids.dedup();
+ self.declined_candidate_ids.sort();
+ self.declined_candidate_ids.dedup();
+ self.missing_parent_ids.sort();
+ self.missing_parent_ids.dedup();
+ self.missing_proposal_ids.sort();
+ self.missing_proposal_ids.dedup();
+ self.unsupported_mutation_ids.sort();
+ self.unsupported_mutation_ids.dedup();
+ self.agreement_claims
+ .sort_by(|left, right| left.claim_mutation_id.cmp(&right.claim_mutation_id));
+ self.issues.sort();
+ self.issues.dedup();
+ self.attestations
+ .sort_by(|left, right| left.event_id.cmp(&right.event_id));
+ self.projection_digest = projection_digest(self);
}
+}
- #[cfg(feature = "serde_json")]
- {
- apply_validation_receipts(
- order_id,
- &mut projection,
- records.expected_listing_event_id.as_ref(),
- records.validation_receipts,
- );
- }
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub struct RadrootsTradeAgreementClaimV1 {
+ pub claim_mutation_id: RadrootsTradeMutationId,
+ pub proposal_mutation_id: RadrootsTradeMutationId,
+ pub candidate_id: RadrootsTradeCandidateId,
+ pub candidate_author_pubkey: RadrootsPublicKey,
+ pub accepted_by_pubkey: RadrootsPublicKey,
+ pub reservation_commitment: String,
+}
- projection
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub enum RadrootsTradeReducerIssueV1 {
+ MissingRootProposal,
+ MultipleRootProposals,
+ MissingMutationId,
+ TradeIdentityMismatch {
+ mutation_id: RadrootsTradeMutationId,
+ },
+ UnsupportedSchema {
+ mutation_id: RadrootsTradeMutationId,
+ schema_version: u16,
+ },
+ InvalidMutation {
+ mutation_id: Option<RadrootsTradeMutationId>,
+ reason: String,
+ },
+ MissingParent {
+ mutation_id: RadrootsTradeMutationId,
+ parent_mutation_id: RadrootsTradeMutationId,
+ },
+ MissingProposal {
+ decision_mutation_id: RadrootsTradeMutationId,
+ proposal_mutation_id: RadrootsTradeMutationId,
+ },
+ CandidateIdMismatch {
+ decision_mutation_id: RadrootsTradeMutationId,
+ proposal_mutation_id: RadrootsTradeMutationId,
+ },
+ DecisionAuthorMismatch {
+ decision_mutation_id: RadrootsTradeMutationId,
+ },
+ DecisionParentMissing {
+ decision_mutation_id: RadrootsTradeMutationId,
+ proposal_mutation_id: RadrootsTradeMutationId,
+ },
+ MissingSellerReservation {
+ decision_mutation_id: RadrootsTradeMutationId,
+ },
+ ReservationCandidateMismatch {
+ decision_mutation_id: RadrootsTradeMutationId,
+ },
+ ReservationAuthorityMismatch {
+ decision_mutation_id: RadrootsTradeMutationId,
+ },
+ ReservationLineMismatch {
+ decision_mutation_id: RadrootsTradeMutationId,
+ },
+ DecisionConflict {
+ proposal_mutation_id: RadrootsTradeMutationId,
+ },
+ DoubleAcceptance {
+ proposal_mutation_id: RadrootsTradeMutationId,
+ },
+ CancellationConflict {
+ cancellation_mutation_id: RadrootsTradeMutationId,
+ },
+ InvalidCausalChain {
+ mutation_id: RadrootsTradeMutationId,
+ },
+ PrivateTermsUnavailable {
+ candidate_id: RadrootsTradeCandidateId,
+ },
+ ProjectionDigestUnavailable {
+ reason: String,
+ },
}
-pub fn reduce_trade_workflow_records_for_trade_locator(
- locator: &RadrootsTradeLocator,
- mut records: RadrootsTradeWorkflowRecords,
-) -> RadrootsTradeLocatorProjectionResolution {
- let order_events = core::mem::take(&mut records.order_events);
- match grouped_order_event_records_for_trade_locator(locator, order_events) {
- RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Missing { locator } => {
- RadrootsTradeLocatorProjectionResolution::Missing { locator }
- }
- RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Ambiguous {
- locator,
- candidates,
- } => RadrootsTradeLocatorProjectionResolution::Ambiguous {
- locator,
- candidates,
- },
- RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Matched {
- locator,
- records: order_events,
- } => {
- #[cfg(feature = "serde_json")]
- {
- let root_event_id = locator
- .root_event_id
- .as_ref()
- .map(RadrootsEventId::as_str)
- .unwrap_or("");
- records
- .validation_receipts
- .retain(|receipt| receipt.tags.root_event_id == root_event_id);
- }
- records.order_events = order_events;
- let projection = reduce_trade_workflow_records(locator.order_id(), records);
- RadrootsTradeLocatorProjectionResolution::Projected {
- locator,
- projection,
- }
- }
- }
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct CandidateRecord {
+ proposal_mutation_id: RadrootsTradeMutationId,
+ author_pubkey: RadrootsPublicKey,
+ candidate: RadrootsTradeCandidateTermsV1,
}
-#[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;
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct CancellationRecord {
+ mutation_id: RadrootsTradeMutationId,
+ parent_mutation_ids: Vec<RadrootsTradeMutationId>,
+ target_candidate_id: Option<RadrootsTradeCandidateId>,
+ target_claim_mutation_id: Option<RadrootsTradeMutationId>,
+}
+
+pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTradeProjectionV1 {
+ let mut projection = RadrootsTradeProjectionV1::empty(input.trade_id.clone());
+ let mut mutations = BTreeMap::<RadrootsTradeMutationId, RadrootsTradeMutationEnvelopeV1>::new();
+
+ for record in input.mutations {
+ let mutation_id = match record.mutation.mutation_id.clone() {
+ Some(mutation_id) => mutation_id,
+ None => {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::MissingMutationId);
+ continue;
+ }
+ };
+ if record.mutation.schema_version != RADROOTS_TRADE_SCHEMA_VERSION {
+ projection
+ .unsupported_mutation_ids
+ .push(mutation_id.clone());
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::UnsupportedSchema {
+ mutation_id: mutation_id.clone(),
+ schema_version: record.mutation.schema_version,
+ });
+ mutations.entry(mutation_id).or_insert(record.mutation);
+ continue;
+ }
+ if let Err(error) = record.mutation.validate() {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::InvalidMutation {
+ mutation_id: Some(mutation_id.clone()),
+ reason: error.to_string(),
+ });
+ continue;
+ }
+ if record.mutation.trade_id != input.trade_id {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::TradeIdentityMismatch {
+ mutation_id: mutation_id.clone(),
+ });
+ continue;
+ }
+ mutations.entry(mutation_id).or_insert(record.mutation);
}
- 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);
+ let mut root_proposals = Vec::<RadrootsTradeMutationId>::new();
+ let mut candidates_by_proposal = BTreeMap::<RadrootsTradeMutationId, CandidateRecord>::new();
+ let mut claims = BTreeMap::<RadrootsTradeMutationId, RadrootsTradeAgreementClaimV1>::new();
+ let mut decisions_by_proposal =
+ BTreeMap::<RadrootsTradeMutationId, Vec<RadrootsTradeMutationId>>::new();
+ let mut decision_ids = Vec::<RadrootsTradeMutationId>::new();
+ let mut cancellations = Vec::<CancellationRecord>::new();
+ let mut referenced_parents = BTreeSet::<RadrootsTradeMutationId>::new();
+
+ for (mutation_id, mutation) in &mutations {
+ if mutation.parent_mutation_ids.is_empty()
+ && matches!(mutation.body, RadrootsTradeMutationBodyV1::Proposal { .. })
+ {
+ root_proposals.push(mutation_id.clone());
+ }
+ for parent in &mutation.parent_mutation_ids {
+ referenced_parents.insert(parent.clone());
+ if !mutations.contains_key(parent) {
+ projection.missing_parent_ids.push(parent.clone());
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::MissingParent {
+ mutation_id: mutation_id.clone(),
+ parent_mutation_id: parent.clone(),
+ });
+ }
+ }
+ match &mutation.body {
+ RadrootsTradeMutationBodyV1::Proposal { candidate }
+ | RadrootsTradeMutationBodyV1::RevisionProposal { candidate } => {
+ if let Some(candidate_id) = candidate.candidate_id.clone() {
+ candidates_by_proposal.insert(
+ mutation_id.clone(),
+ CandidateRecord {
+ proposal_mutation_id: mutation_id.clone(),
+ author_pubkey: mutation.author_pubkey.clone(),
+ candidate: candidate.clone(),
+ },
+ );
+ let _ = candidate_id;
+ }
+ }
+ RadrootsTradeMutationBodyV1::Decision {
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ }
+ | RadrootsTradeMutationBodyV1::RevisionDecision {
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ } => {
+ let _ = (proposal_mutation_id, candidate_id, decision);
+ decision_ids.push(mutation_id.clone());
+ }
+ RadrootsTradeMutationBodyV1::Cancellation {
+ target_candidate_id,
+ target_claim_mutation_id,
+ reason: _,
+ } => cancellations.push(CancellationRecord {
+ mutation_id: mutation_id.clone(),
+ parent_mutation_ids: mutation.parent_mutation_ids.clone(),
+ target_candidate_id: target_candidate_id.clone(),
+ target_claim_mutation_id: target_claim_mutation_id.clone(),
+ }),
}
}
- if !projection.issues.is_empty() {
- projection.status = RadrootsTradeWorkflowState::Invalid;
- projection.lifecycle_terminal = true;
- projection.pending_inventory_reservations.clear();
- projection.finish_issue_state();
- return;
+ for mutation_id in decision_ids {
+ let Some(mutation) = mutations.get(&mutation_id) else {
+ continue;
+ };
+ match &mutation.body {
+ RadrootsTradeMutationBodyV1::Decision {
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ }
+ | RadrootsTradeMutationBodyV1::RevisionDecision {
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ } => {
+ decisions_by_proposal
+ .entry(proposal_mutation_id.clone())
+ .or_default()
+ .push(mutation_id.clone());
+ apply_decision(
+ &mutation_id,
+ mutation,
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ &candidates_by_proposal,
+ &mut claims,
+ &mut projection,
+ );
+ }
+ _ => {}
+ }
}
- 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();
+ if root_proposals.is_empty() {
projection
.issues
- .push(RadrootsOrderIssue::ConflictingValidationReceipts { event_ids });
- projection.finish_issue_state();
- return;
+ .push(RadrootsTradeReducerIssueV1::MissingRootProposal);
+ } else if root_proposals.len() > 1 {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::MultipleRootProposals);
+ } else {
+ projection.root_mutation_id = root_proposals.first().cloned();
+ if let Some(root) = projection
+ .root_mutation_id
+ .as_ref()
+ .and_then(|root_id| mutations.get(root_id))
+ {
+ projection.buyer_pubkey = Some(root.buyer_pubkey.clone());
+ projection.seller_pubkey = Some(root.seller_pubkey.clone());
+ projection.farm_id = Some(root.farm_id.clone());
+ }
}
- let receipt = &valid_receipts[0];
-
- 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());
+ for (proposal_mutation_id, decision_ids) in &decisions_by_proposal {
+ let unique: BTreeSet<RadrootsTradeMutationId> = decision_ids.iter().cloned().collect();
+ if unique.len() > 1 {
+ let accept_count = unique
+ .iter()
+ .filter(|decision_id| {
+ mutations
+ .get(*decision_id)
+ .is_some_and(|mutation| is_acceptance(&mutation.body))
+ })
+ .count();
+ if accept_count > 1 {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::DoubleAcceptance {
+ proposal_mutation_id: proposal_mutation_id.clone(),
+ });
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::DoubleAcceptance,
+ );
+ }
+ if accept_count > 0 && accept_count < unique.len() {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::DecisionConflict {
+ proposal_mutation_id: proposal_mutation_id.clone(),
+ });
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::DecisionConflict,
+ );
+ }
}
- 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());
+ }
+
+ projection.candidate_heads = mutations
+ .keys()
+ .filter(|mutation_id| !referenced_parents.contains(*mutation_id))
+ .cloned()
+ .collect();
+ projection.declined_candidate_ids = declined_candidate_ids(&mutations);
+ projection.agreement_claims = claims.values().cloned().collect();
+ apply_agreement_state(
+ &mut projection,
+ &claims,
+ &mutations,
+ &candidates_by_proposal,
+ &cancellations,
+ );
+ apply_negotiation_state(
+ &mut projection,
+ &candidates_by_proposal,
+ &claims,
+ input.observed_at_unix_s,
+ );
+ projection.private_terms_state =
+ reduce_private_terms_state(&projection, &candidates_by_proposal, &input.private_terms);
+ if matches!(
+ projection.private_terms_state,
+ RadrootsTradePrivateTermsStateV1::Missing
+ | RadrootsTradePrivateTermsStateV1::Undecryptable
+ | RadrootsTradePrivateTermsStateV1::CommitmentMismatch
+ ) {
+ for claim_id in &projection.active_agreement_claim_ids {
+ if let Some(claim) = claims.get(claim_id) {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::PrivateTermsUnavailable {
+ candidate_id: claim.candidate_id.clone(),
+ });
+ }
}
}
+ projection.attestations = input.attestations;
+ projection.attestation_state = reduce_attestation_state(&projection.attestations);
+ projection.evidence_state = reduce_evidence_state(&projection, input.evidence_state);
+ projection.finish();
+ projection
}
-#[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::AgreedPendingValidation {
- projection.issues.push(
- RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement {
- event_id: receipt.event_id.clone(),
- },
+fn apply_decision(
+ mutation_id: &RadrootsTradeMutationId,
+ mutation: &RadrootsTradeMutationEnvelopeV1,
+ proposal_mutation_id: &RadrootsTradeMutationId,
+ candidate_id: &RadrootsTradeCandidateId,
+ decision: &RadrootsTradeDecisionV1,
+ candidates_by_proposal: &BTreeMap<RadrootsTradeMutationId, CandidateRecord>,
+ claims: &mut BTreeMap<RadrootsTradeMutationId, RadrootsTradeAgreementClaimV1>,
+ projection: &mut RadrootsTradeProjectionV1,
+) {
+ let Some(candidate_record) = candidates_by_proposal.get(proposal_mutation_id) else {
+ projection
+ .missing_proposal_ids
+ .push(proposal_mutation_id.clone());
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::MissingProposal {
+ decision_mutation_id: mutation_id.clone(),
+ proposal_mutation_id: proposal_mutation_id.clone(),
+ });
+ return;
+ };
+ if candidate_record.candidate.candidate_id.as_ref() != Some(candidate_id) {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::CandidateIdMismatch {
+ decision_mutation_id: mutation_id.clone(),
+ proposal_mutation_id: proposal_mutation_id.clone(),
+ });
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::DecisionConflict,
);
- valid = false;
+ return;
}
- if &receipt.order_id != order_id || receipt.tags.order_id != order_id.as_str() {
+ if mutation.author_pubkey != mutation.counterparty_pubkey
+ && mutation.author_pubkey
+ != candidate_record_author_counterparty(candidate_record, mutation)
+ {
projection
.issues
- .push(RadrootsOrderIssue::ValidationReceiptOrderIdMismatch {
- event_id: receipt.event_id.clone(),
+ .push(RadrootsTradeReducerIssueV1::DecisionAuthorMismatch {
+ decision_mutation_id: mutation_id.clone(),
});
- valid = false;
+ return;
}
- if receipt.receipt.receipt_type != RadrootsValidationReceiptType::TradeTransition
- || receipt.tags.receipt_type != RadrootsValidationReceiptType::TradeTransition
- {
+ if !mutation.parent_mutation_ids.contains(proposal_mutation_id) {
projection
.issues
- .push(RadrootsOrderIssue::ValidationReceiptTypeMismatch {
- event_id: receipt.event_id.clone(),
+ .push(RadrootsTradeReducerIssueV1::DecisionParentMissing {
+ decision_mutation_id: mutation_id.clone(),
+ proposal_mutation_id: proposal_mutation_id.clone(),
});
- valid = false;
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::InvalidCausalChain,
+ );
}
- if projection
- .request_event_id
- .as_ref()
- .is_none_or(|root| receipt.tags.root_event_id != root.as_str())
- {
+ match decision {
+ RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion,
+ } => {
+ let Some(reservation) = reservation_assertion else {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::MissingSellerReservation {
+ decision_mutation_id: mutation_id.clone(),
+ });
+ return;
+ };
+ if validate_reservation(
+ mutation_id,
+ candidate_id,
+ &candidate_record.candidate,
+ reservation,
+ projection,
+ ) {
+ claims.insert(
+ mutation_id.clone(),
+ RadrootsTradeAgreementClaimV1 {
+ claim_mutation_id: mutation_id.clone(),
+ proposal_mutation_id: candidate_record.proposal_mutation_id.clone(),
+ candidate_id: candidate_id.clone(),
+ candidate_author_pubkey: candidate_record.author_pubkey.clone(),
+ accepted_by_pubkey: mutation.author_pubkey.clone(),
+ reservation_commitment: reservation.assertion_commitment.clone(),
+ },
+ );
+ }
+ }
+ RadrootsTradeDecisionV1::Declined { .. } => {}
+ }
+}
+
+fn candidate_record_author_counterparty(
+ candidate_record: &CandidateRecord,
+ mutation: &RadrootsTradeMutationEnvelopeV1,
+) -> RadrootsPublicKey {
+ if candidate_record.author_pubkey == mutation.buyer_pubkey {
+ mutation.seller_pubkey.clone()
+ } else {
+ mutation.buyer_pubkey.clone()
+ }
+}
+
+fn validate_reservation(
+ decision_mutation_id: &RadrootsTradeMutationId,
+ candidate_id: &RadrootsTradeCandidateId,
+ candidate: &RadrootsTradeCandidateTermsV1,
+ reservation: &RadrootsSellerReservationAssertionV1,
+ projection: &mut RadrootsTradeProjectionV1,
+) -> bool {
+ let mut valid = true;
+ if &reservation.candidate_id != candidate_id {
projection
.issues
- .push(RadrootsOrderIssue::ValidationReceiptRootMismatch {
- event_id: receipt.event_id.clone(),
+ .push(RadrootsTradeReducerIssueV1::ReservationCandidateMismatch {
+ decision_mutation_id: decision_mutation_id.clone(),
});
valid = false;
}
- if projection
- .agreement_event_id
- .as_ref()
- .is_none_or(|target| receipt.tags.target_event_id != target.as_str())
- {
+ if reservation.inventory_authority_id != candidate.seller_pubkey {
projection
.issues
- .push(RadrootsOrderIssue::ValidationReceiptTargetMismatch {
- event_id: receipt.event_id.clone(),
+ .push(RadrootsTradeReducerIssueV1::ReservationAuthorityMismatch {
+ decision_mutation_id: decision_mutation_id.clone(),
});
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::InventoryAuthorityConflict,
+ );
valid = false;
}
- if let Some(listing_event_id) = expected_listing_event_id
- && receipt.tags.listing_event_id != listing_event_id.as_str()
- {
+ if reservation.commitments.len() != candidate.lines.len() {
projection
.issues
- .push(RadrootsOrderIssue::ValidationReceiptListingMismatch {
- event_id: receipt.event_id.clone(),
+ .push(RadrootsTradeReducerIssueV1::ReservationLineMismatch {
+ decision_mutation_id: decision_mutation_id.clone(),
});
- valid = false;
+ return false;
+ }
+ for (line, commitment) in candidate.lines.iter().zip(reservation.commitments.iter()) {
+ if line.line_id != commitment.line_id
+ || line.bin_id != commitment.bin_id
+ || line.quantity_mantissa != commitment.quantity_mantissa
+ || line.quantity_scale != commitment.quantity_scale
+ || line.unit_code != commitment.unit_code
+ {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::ReservationLineMismatch {
+ decision_mutation_id: decision_mutation_id.clone(),
+ });
+ valid = false;
+ break;
+ }
}
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
+fn apply_agreement_state(
+ projection: &mut RadrootsTradeProjectionV1,
+ claims: &BTreeMap<RadrootsTradeMutationId, RadrootsTradeAgreementClaimV1>,
+ mutations: &BTreeMap<RadrootsTradeMutationId, RadrootsTradeMutationEnvelopeV1>,
+ candidates_by_proposal: &BTreeMap<RadrootsTradeMutationId, CandidateRecord>,
+ cancellations: &[CancellationRecord],
+) {
+ if claims.is_empty() {
+ if cancellation_without_claim(cancellations, candidates_by_proposal) {
+ projection.agreement_state = RadrootsTradeAgreementStateV1::Cancelled;
+ }
+ return;
+ }
+
+ let active_claim_ids = non_dominated_claim_ids(claims, mutations);
+ let active_claims = active_claim_ids
+ .iter()
+ .filter_map(|claim_id| claims.get(claim_id))
+ .collect::<Vec<_>>();
+ let compatible = compatible_claims(&active_claims);
+ projection.active_agreement_claim_ids = active_claim_ids;
+
+ if compatible {
+ projection.agreement_state = RadrootsTradeAgreementStateV1::Agreed;
+ } else {
+ projection.agreement_state = RadrootsTradeAgreementStateV1::Contested;
+ projection.contested_claim_ids = projection.active_agreement_claim_ids.clone();
+ if projection.conflict_state == RadrootsTradeConflictStateV1::None {
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::DecisionConflict,
+ );
+ }
+ }
+
+ for cancellation in cancellations {
+ if let Some(target_claim_id) = &cancellation.target_claim_mutation_id
+ && claims.contains_key(target_claim_id)
+ {
+ if cancellation.parent_mutation_ids.contains(target_claim_id) {
+ projection.cancelled_claim_ids.push(target_claim_id.clone());
+ if projection.active_agreement_claim_ids == [target_claim_id.clone()] {
+ projection.agreement_state = RadrootsTradeAgreementStateV1::Cancelled;
+ }
+ } else {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::CancellationConflict {
+ cancellation_mutation_id: cancellation.mutation_id.clone(),
+ });
+ projection.agreement_state = RadrootsTradeAgreementStateV1::Contested;
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::CancellationConflict,
+ );
+ }
+ }
+ }
}
-#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
-mod tests {
- use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit,
- };
- use radroots_event::{
- ids::{
- RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId,
- RadrootsOrderQuoteId, RadrootsPublicKey,
- },
- kinds::KIND_LISTING,
- order::{
- RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome,
- RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderInventoryCommitment,
- RadrootsOrderItem, RadrootsOrderPricingBasis, RadrootsOrderRequest,
- },
- };
+fn cancellation_without_claim(
+ cancellations: &[CancellationRecord],
+ candidates_by_proposal: &BTreeMap<RadrootsTradeMutationId, CandidateRecord>,
+) -> bool {
+ cancellations.iter().any(|cancellation| {
+ cancellation
+ .target_candidate_id
+ .as_ref()
+ .is_some_and(|candidate_id| {
+ candidates_by_proposal.values().any(|candidate| {
+ candidate.candidate.candidate_id.as_ref() == Some(candidate_id)
+ && candidate.candidate.cancellation.buyer_pre_agreement
+ })
+ })
+ })
+}
- use crate::order::{
- RadrootsGroupedOrderEventRecords, RadrootsOrderCancellationRecord,
- RadrootsOrderDecisionRecord, RadrootsOrderIssue, RadrootsOrderRequestRecord,
- RadrootsTradeLocatorProjectionResolution,
- };
- use crate::validation_receipt::{
- RadrootsTradeValidationReceipt, RadrootsValidationReceiptProof,
- RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult,
- RadrootsValidationReceiptStatement, RadrootsValidationReceiptType,
- validation_receipt_public_values_hash_hex, validation_receipt_tags,
- validation_receipt_tags_from_tags, validator_set_address_from_str,
- };
+fn non_dominated_claim_ids(
+ claims: &BTreeMap<RadrootsTradeMutationId, RadrootsTradeAgreementClaimV1>,
+ mutations: &BTreeMap<RadrootsTradeMutationId, RadrootsTradeMutationEnvelopeV1>,
+) -> Vec<RadrootsTradeMutationId> {
+ let mut memo = BTreeMap::<RadrootsTradeMutationId, BTreeSet<RadrootsTradeMutationId>>::new();
+ claims
+ .keys()
+ .filter(|claim_id| {
+ !claims.keys().any(|other_claim_id| {
+ other_claim_id != *claim_id
+ && ancestors_of(other_claim_id, mutations, &mut memo).contains(*claim_id)
+ })
+ })
+ .cloned()
+ .collect()
+}
- use super::{
- RadrootsTradeWorkflowDeterministicFailure, RadrootsTradeWorkflowRecords,
- RadrootsTradeWorkflowState, RadrootsTradeWorkflowValidationReceiptRecord,
- inventory_reservations_from_commitments, reduce_trade_workflow_records,
- reduce_trade_workflow_records_for_trade_locator,
+fn compatible_claims(claims: &[&RadrootsTradeAgreementClaimV1]) -> bool {
+ let Some(first) = claims.first() else {
+ return false;
};
- use crate::identity::RadrootsTradeLocator;
+ claims.iter().all(|claim| {
+ claim.candidate_id == first.candidate_id
+ && claim.reservation_commitment == first.reservation_commitment
+ })
+}
- const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
- const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+fn ancestors_of(
+ mutation_id: &RadrootsTradeMutationId,
+ mutations: &BTreeMap<RadrootsTradeMutationId, RadrootsTradeMutationEnvelopeV1>,
+ memo: &mut BTreeMap<RadrootsTradeMutationId, BTreeSet<RadrootsTradeMutationId>>,
+) -> BTreeSet<RadrootsTradeMutationId> {
+ if let Some(cached) = memo.get(mutation_id) {
+ return cached.clone();
+ }
+ let mut ancestors = BTreeSet::new();
+ if let Some(mutation) = mutations.get(mutation_id) {
+ for parent in &mutation.parent_mutation_ids {
+ ancestors.insert(parent.clone());
+ ancestors.extend(ancestors_of(parent, mutations, memo));
+ }
+ }
+ memo.insert(mutation_id.clone(), ancestors.clone());
+ ancestors
+}
- #[cfg(feature = "serde_json")]
- #[test]
- fn workflow_state_serializes_as_approved_wire_vocabulary() {
- for (state, wire_name) in [
- (RadrootsTradeWorkflowState::Missing, "missing"),
- (RadrootsTradeWorkflowState::Requested, "requested"),
- (
- RadrootsTradeWorkflowState::AgreedPendingValidation,
- "agreed_pending_validation",
- ),
- (RadrootsTradeWorkflowState::Committed, "committed"),
- (RadrootsTradeWorkflowState::Declined, "declined"),
- (RadrootsTradeWorkflowState::Cancelled, "cancelled"),
- (
- RadrootsTradeWorkflowState::ValidationExpired,
- "validation_expired",
- ),
- (RadrootsTradeWorkflowState::Invalid, "invalid"),
- ] {
- assert_eq!(serde_json::to_value(&state).unwrap(), wire_name);
- assert_eq!(
- serde_json::from_value::<RadrootsTradeWorkflowState>(wire_name.into()).unwrap(),
- state
+fn apply_negotiation_state(
+ projection: &mut RadrootsTradeProjectionV1,
+ candidates_by_proposal: &BTreeMap<RadrootsTradeMutationId, CandidateRecord>,
+ claims: &BTreeMap<RadrootsTradeMutationId, RadrootsTradeAgreementClaimV1>,
+ observed_at_unix_s: Option<u64>,
+) {
+ if candidates_by_proposal.is_empty() {
+ projection.negotiation_state = RadrootsTradeNegotiationStateV1::None;
+ } else if claims.is_empty()
+ && observed_at_unix_s.is_some_and(|observed_at| {
+ candidates_by_proposal
+ .values()
+ .all(|candidate| candidate.candidate.proposal_expires_at_unix_s <= observed_at)
+ })
+ {
+ projection.negotiation_state = RadrootsTradeNegotiationStateV1::ClosedExpired;
+ } else if claims.is_empty() && !projection.declined_candidate_ids.is_empty() {
+ projection.negotiation_state = RadrootsTradeNegotiationStateV1::ClosedDeclined;
+ } else {
+ projection.negotiation_state = RadrootsTradeNegotiationStateV1::Open;
+ }
+}
+
+fn reduce_private_terms_state(
+ projection: &RadrootsTradeProjectionV1,
+ candidates_by_proposal: &BTreeMap<RadrootsTradeMutationId, CandidateRecord>,
+ private_terms: &[RadrootsTradePrivateTermsEvidenceV1],
+) -> RadrootsTradePrivateTermsStateV1 {
+ let private_terms_by_candidate = private_terms
+ .iter()
+ .map(|record| (record.candidate_id.clone(), record.state))
+ .collect::<BTreeMap<_, _>>();
+ let mut required_states = Vec::new();
+ for claim in &projection.agreement_claims {
+ let Some(candidate_record) = candidates_by_proposal.get(&claim.proposal_mutation_id) else {
+ continue;
+ };
+ let requires_private_terms = candidate_record.candidate.private_terms.is_some()
+ || candidate_record
+ .candidate
+ .fulfillment
+ .requires_private_terms;
+ if requires_private_terms {
+ required_states.push(
+ private_terms_by_candidate
+ .get(&claim.candidate_id)
+ .copied()
+ .unwrap_or(RadrootsTradePrivateTermsStateV1::Missing),
);
}
}
+ if required_states.is_empty() {
+ RadrootsTradePrivateTermsStateV1::NotRequired
+ } else if required_states
+ .iter()
+ .any(|state| *state == RadrootsTradePrivateTermsStateV1::CommitmentMismatch)
+ {
+ RadrootsTradePrivateTermsStateV1::CommitmentMismatch
+ } else if required_states
+ .iter()
+ .any(|state| *state == RadrootsTradePrivateTermsStateV1::Undecryptable)
+ {
+ RadrootsTradePrivateTermsStateV1::Undecryptable
+ } else if required_states
+ .iter()
+ .any(|state| *state == RadrootsTradePrivateTermsStateV1::Missing)
+ {
+ RadrootsTradePrivateTermsStateV1::Missing
+ } else {
+ RadrootsTradePrivateTermsStateV1::AvailableVerified
+ }
+}
+
+fn reduce_attestation_state(
+ attestations: &[RadrootsTradeAttestationRecordV1],
+) -> RadrootsTradeAttestationStateV1 {
+ let mut has_valid = false;
+ let mut has_invalid = false;
+ for attestation in attestations {
+ match attestation.result {
+ RadrootsTradeAttestationResultV1::Valid => has_valid = true,
+ RadrootsTradeAttestationResultV1::Invalid => has_invalid = true,
+ }
+ }
+ match (has_valid, has_invalid) {
+ (false, false) => RadrootsTradeAttestationStateV1::None,
+ (true, false) => RadrootsTradeAttestationStateV1::PresentValid,
+ (false, true) => RadrootsTradeAttestationStateV1::PresentInvalid,
+ (true, true) => RadrootsTradeAttestationStateV1::Conflicting,
+ }
+}
+
+fn reduce_evidence_state(
+ projection: &RadrootsTradeProjectionV1,
+ requested_state: RadrootsTradeEvidenceStateV1,
+) -> RadrootsTradeEvidenceStateV1 {
+ if !projection.unsupported_mutation_ids.is_empty() {
+ RadrootsTradeEvidenceStateV1::UnsupportedVersion
+ } else if !projection.missing_parent_ids.is_empty()
+ || !projection.missing_proposal_ids.is_empty()
+ || projection.root_mutation_id.is_none()
+ {
+ RadrootsTradeEvidenceStateV1::Missing
+ } else {
+ requested_state
+ }
+}
+
+fn declined_candidate_ids(
+ mutations: &BTreeMap<RadrootsTradeMutationId, RadrootsTradeMutationEnvelopeV1>,
+) -> Vec<RadrootsTradeCandidateId> {
+ mutations
+ .values()
+ .filter_map(|mutation| match &mutation.body {
+ RadrootsTradeMutationBodyV1::Decision {
+ candidate_id,
+ decision: RadrootsTradeDecisionV1::Declined { .. },
+ ..
+ }
+ | RadrootsTradeMutationBodyV1::RevisionDecision {
+ candidate_id,
+ decision: RadrootsTradeDecisionV1::Declined { .. },
+ ..
+ } => Some(candidate_id.clone()),
+ _ => None,
+ })
+ .collect()
+}
+
+fn is_acceptance(body: &RadrootsTradeMutationBodyV1) -> bool {
+ matches!(
+ body,
+ RadrootsTradeMutationBodyV1::Decision {
+ decision: RadrootsTradeDecisionV1::Accepted { .. },
+ ..
+ } | RadrootsTradeMutationBodyV1::RevisionDecision {
+ decision: RadrootsTradeDecisionV1::Accepted { .. },
+ ..
+ }
+ )
+}
+
+fn set_conflict(
+ current: &mut RadrootsTradeConflictStateV1,
+ candidate: RadrootsTradeConflictStateV1,
+) {
+ if *current == RadrootsTradeConflictStateV1::None || candidate > *current {
+ *current = candidate;
+ }
+}
+
+#[cfg(feature = "serde_json")]
+fn projection_digest(projection: &RadrootsTradeProjectionV1) -> String {
+ let mut digest_input = projection.clone();
+ digest_input.projection_digest.clear();
+ let value = match serde_json::to_value(&digest_input) {
+ Ok(value) => value,
+ Err(error) => {
+ let mut hasher = Sha256::new();
+ hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN);
+ hasher.update(error.to_string().as_bytes());
+ return hex::encode(hasher.finalize());
+ }
+ };
+ let canonical = match radroots_event::trade::canonical_jcs_value(&value) {
+ Ok(canonical) => canonical,
+ Err(error) => {
+ let mut hasher = Sha256::new();
+ hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN);
+ hasher.update(error.to_string().as_bytes());
+ return hex::encode(hasher.finalize());
+ }
+ };
+ let mut hasher = Sha256::new();
+ hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN);
+ hasher.update(canonical.as_bytes());
+ hex::encode(hasher.finalize())
+}
+
+#[cfg(not(feature = "serde_json"))]
+fn projection_digest(_projection: &RadrootsTradeProjectionV1) -> String {
+ String::new()
+}
+
+#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+mod tests {
+ use super::*;
+ use radroots_event::{
+ ids::{RadrootsAddressableCoordinate, RadrootsInventoryBinId},
+ trade::{
+ RADROOTS_TRADE_DECISION_CONTRACT_ID, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID,
+ RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID,
+ RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, RadrootsFulfillmentProfileV1,
+ RadrootsSellerReservationLineV1, RadrootsTradeCancellationProfileV1,
+ RadrootsTradeCandidateLineV1, RadrootsTradeEconomicAdjustmentV1,
+ RadrootsTradeEconomicsProfileV1, RadrootsTradeLineTombstoneV1,
+ RadrootsTradePrivateTermsRefV1, canonical_trade_mutation_content,
+ },
+ };
+
+ fn hex_64(character: char) -> String {
+ core::iter::repeat_n(character, 64).collect()
+ }
- fn event_id(raw: u8) -> RadrootsEventId {
- RadrootsEventId::parse(format!("{raw:064x}")).expect("event id")
+ fn hex_32(character: char) -> String {
+ core::iter::repeat_n(character, 32).collect()
}
- fn public_key(raw: &str) -> RadrootsPublicKey {
- RadrootsPublicKey::parse(raw).expect("public key")
+ fn pubkey(character: char) -> RadrootsPublicKey {
+ RadrootsPublicKey::parse(hex_64(character)).unwrap()
}
- fn order_id() -> RadrootsOrderId {
- RadrootsOrderId::parse("order-1").expect("order id")
+ fn event_id(character: char) -> RadrootsEventId {
+ RadrootsEventId::parse(hex_64(character)).unwrap()
}
- fn quote_id(raw: &str) -> RadrootsOrderQuoteId {
- RadrootsOrderQuoteId::parse(raw).expect("quote id")
+ fn trade_id() -> RadrootsTradeId {
+ RadrootsTradeId::parse(hex_32('1')).unwrap()
}
- fn bin_id(raw: &str) -> RadrootsInventoryBinId {
- RadrootsInventoryBinId::parse(raw).expect("bin id")
+ fn dtag(value: &str) -> RadrootsDTag {
+ RadrootsDTag::parse(value).unwrap()
}
- fn listing_addr() -> RadrootsListingAddress {
- RadrootsListingAddress::parse(format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"))
- .expect("listing address")
+ fn bin_id(value: &str) -> RadrootsInventoryBinId {
+ RadrootsInventoryBinId::parse(value).unwrap()
}
- 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,
- ),
+ fn candidate(line_suffix: &str) -> RadrootsTradeCandidateTermsV1 {
+ RadrootsTradeCandidateTermsV1 {
+ candidate_id: None,
+ schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
+ base_candidate_id: None,
+ supersession_intent: None,
+ buyer_pubkey: pubkey('a'),
+ seller_pubkey: pubkey('b'),
+ farm_id: dtag("farm-1"),
+ lines: vec![RadrootsTradeCandidateLineV1 {
+ line_id: dtag(&format!("line-{line_suffix}")),
+ listing_addr: RadrootsAddressableCoordinate::parse(format!(
+ "30402:{}:listing-{line_suffix}",
+ hex_64('b')
+ ))
+ .unwrap(),
+ listing_event_id: event_id('c'),
+ listing_snapshot_sha256: hex_64('d'),
+ product_id: format!("carrots-{line_suffix}"),
+ option_id: None,
+ bin_id: bin_id(&format!("bin-{line_suffix}")),
+ quantity_mantissa: "2".to_string(),
+ quantity_scale: 0,
+ unit_code: "count".to_string(),
+ unit_profile: "mvp-count".to_string(),
+ unit_price_mantissa: "500".to_string(),
+ currency_code: "USD".to_string(),
+ line_subtotal_mantissa: "1000".to_string(),
+ replaces_line_id: None,
}],
- 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,
- ),
+ line_tombstones: Vec::<RadrootsTradeLineTombstoneV1>::new(),
+ economics: RadrootsTradeEconomicsProfileV1 {
+ profile_id: "mvp-fixed".to_string(),
+ currency_code: "USD".to_string(),
+ currency_exponent: 2,
+ rounding_profile: "half-even".to_string(),
+ subtotal_mantissa: "1000".to_string(),
+ discount_total_mantissa: "0".to_string(),
+ adjustment_total_mantissa: "0".to_string(),
+ total_mantissa: "1000".to_string(),
+ adjustments: Vec::<RadrootsTradeEconomicAdjustmentV1>::new(),
+ },
+ fulfillment: RadrootsFulfillmentProfileV1 {
+ profile_id: "market-pickup".to_string(),
+ method: "pickup".to_string(),
+ starts_at_unix_s: 1_800_000_000,
+ ends_at_unix_s: 1_800_003_600,
+ timezone: "America/New_York".to_string(),
+ utc_offset_seconds: -18_000,
+ fold: 0,
+ location_class: "farmstand".to_string(),
+ requires_private_terms: false,
+ },
+ cancellation: RadrootsTradeCancellationProfileV1 {
+ profile_id: "buyer-pre-agreement".to_string(),
+ buyer_pre_agreement: true,
+ post_agreement_cutoff_unix_s: Some(1_799_990_000),
+ },
+ private_terms: None,
+ proposal_expires_at_unix_s: 1_799_999_000,
}
}
- 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 proposal() -> RadrootsTradeMutationEnvelopeV1 {
+ canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 {
+ mutation_id: None,
+ contract_id: RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_string(),
+ schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
+ trade_id: trade_id(),
+ root_mutation_id: None,
+ buyer_pubkey: pubkey('a'),
+ seller_pubkey: pubkey('b'),
+ farm_id: dtag("farm-1"),
+ parent_mutation_ids: Vec::new(),
+ author_pubkey: pubkey('a'),
+ counterparty_pubkey: pubkey('b'),
+ authored_at_unix_s: 100,
+ body: RadrootsTradeMutationBodyV1::Proposal {
+ candidate: candidate("1"),
},
+ })
+ .unwrap()
+ .envelope
+ }
+
+ fn reservation(
+ candidate: &RadrootsTradeCandidateTermsV1,
+ marker: char,
+ ) -> RadrootsSellerReservationAssertionV1 {
+ RadrootsSellerReservationAssertionV1 {
+ reservation_id: dtag(&format!("reservation-{marker}")),
+ inventory_authority_id: candidate.seller_pubkey.clone(),
+ inventory_epoch: 42,
+ candidate_id: candidate.candidate_id.clone().unwrap(),
+ commitments: candidate
+ .lines
+ .iter()
+ .map(|line| RadrootsSellerReservationLineV1 {
+ line_id: line.line_id.clone(),
+ bin_id: line.bin_id.clone(),
+ quantity_mantissa: line.quantity_mantissa.clone(),
+ quantity_scale: line.quantity_scale,
+ unit_code: line.unit_code.clone(),
+ })
+ .collect(),
+ reservation_expires_at_unix_s: 1_800_000_000,
+ assertion_commitment: hex_64(marker),
}
}
- 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 accepted_decision(
+ proposal: &RadrootsTradeMutationEnvelopeV1,
+ marker: char,
+ ) -> RadrootsTradeMutationEnvelopeV1 {
+ let proposal_id = proposal.mutation_id.clone().unwrap();
+ let candidate = match &proposal.body {
+ RadrootsTradeMutationBodyV1::Proposal { candidate }
+ | RadrootsTradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(),
+ _ => unreachable!(),
+ };
+ canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 {
+ mutation_id: None,
+ contract_id: RADROOTS_TRADE_DECISION_CONTRACT_ID.to_string(),
+ schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
+ trade_id: trade_id(),
+ root_mutation_id: Some(root_id(proposal)),
+ buyer_pubkey: pubkey('a'),
+ seller_pubkey: pubkey('b'),
+ farm_id: dtag("farm-1"),
+ parent_mutation_ids: vec![proposal_id.clone()],
+ author_pubkey: pubkey('b'),
+ counterparty_pubkey: pubkey('a'),
+ authored_at_unix_s: u64::from(marker),
+ body: RadrootsTradeMutationBodyV1::Decision {
+ proposal_mutation_id: proposal_id,
+ candidate_id: candidate.candidate_id.clone().unwrap(),
+ decision: RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: Some(reservation(&candidate, marker)),
},
},
- }
+ })
+ .unwrap()
+ .envelope
}
- 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 declined_decision(
+ proposal: &RadrootsTradeMutationEnvelopeV1,
+ ) -> RadrootsTradeMutationEnvelopeV1 {
+ let proposal_id = proposal.mutation_id.clone().unwrap();
+ let candidate = match &proposal.body {
+ RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
+ _ => unreachable!(),
+ };
+ canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 {
+ mutation_id: None,
+ contract_id: RADROOTS_TRADE_DECISION_CONTRACT_ID.to_string(),
+ schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
+ trade_id: trade_id(),
+ root_mutation_id: Some(root_id(proposal)),
+ buyer_pubkey: pubkey('a'),
+ seller_pubkey: pubkey('b'),
+ farm_id: dtag("farm-1"),
+ parent_mutation_ids: vec![proposal_id.clone()],
+ author_pubkey: pubkey('b'),
+ counterparty_pubkey: pubkey('a'),
+ authored_at_unix_s: 102,
+ body: RadrootsTradeMutationBodyV1::Decision {
+ proposal_mutation_id: proposal_id,
+ candidate_id: candidate.candidate_id.clone().unwrap(),
+ decision: RadrootsTradeDecisionV1::Declined {
+ reason: "unavailable".to_string(),
+ },
},
- }
+ })
+ .unwrap()
+ .envelope
}
- fn workflow_records() -> RadrootsTradeWorkflowRecords {
- RadrootsTradeWorkflowRecords {
- order_events: RadrootsGroupedOrderEventRecords {
- requests: vec![request_record()],
- decisions: Vec::new(),
- cancellations: Vec::new(),
+ fn revision_proposal(
+ root: &RadrootsTradeMutationEnvelopeV1,
+ parents: Vec<RadrootsTradeMutationId>,
+ ) -> RadrootsTradeMutationEnvelopeV1 {
+ let mut parents = parents;
+ parents.sort();
+ canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 {
+ mutation_id: None,
+ contract_id: RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID.to_string(),
+ schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
+ trade_id: trade_id(),
+ root_mutation_id: Some(root_id(root)),
+ buyer_pubkey: pubkey('a'),
+ seller_pubkey: pubkey('b'),
+ farm_id: dtag("farm-1"),
+ parent_mutation_ids: parents,
+ author_pubkey: pubkey('a'),
+ counterparty_pubkey: pubkey('b'),
+ authored_at_unix_s: 200,
+ body: RadrootsTradeMutationBodyV1::RevisionProposal {
+ candidate: candidate("2"),
},
- validation_receipts: Vec::new(),
- deterministic_failures: Vec::new(),
- expected_listing_event_id: Some(event_id(80)),
- current_listing_event_id: Some(event_id(80)),
- }
+ })
+ .unwrap()
+ .envelope
}
- 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()
- }
+ fn revision_acceptance(
+ root: &RadrootsTradeMutationEnvelopeV1,
+ proposal: &RadrootsTradeMutationEnvelopeV1,
+ ) -> RadrootsTradeMutationEnvelopeV1 {
+ let proposal_id = proposal.mutation_id.clone().unwrap();
+ let candidate = match &proposal.body {
+ RadrootsTradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(),
+ _ => unreachable!(),
};
- 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,
+ canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 {
+ mutation_id: None,
+ contract_id: RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID.to_string(),
+ schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
+ trade_id: trade_id(),
+ root_mutation_id: Some(root_id(root)),
+ buyer_pubkey: pubkey('a'),
+ seller_pubkey: pubkey('b'),
+ farm_id: dtag("farm-1"),
+ parent_mutation_ids: vec![proposal_id.clone()],
+ author_pubkey: pubkey('b'),
+ counterparty_pubkey: pubkey('a'),
+ authored_at_unix_s: 201,
+ body: RadrootsTradeMutationBodyV1::RevisionDecision {
+ proposal_mutation_id: proposal_id,
+ candidate_id: candidate.candidate_id.clone().unwrap(),
+ decision: RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: Some(reservation(&candidate, '9')),
+ },
},
- 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(),
- validator_set_addr: validator_set_address_from_str(
- "30381:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:018f3d99-7d35-7c0c-8a0f-7f3b645abcde",
- )
- .expect("validator set address"),
- validator_set_event_id:
- "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
- .to_string(),
- statement_type: RadrootsValidationReceiptType::TradeTransition,
+ })
+ .unwrap()
+ .envelope
+ }
+
+ fn cancellation(
+ root: &RadrootsTradeMutationEnvelopeV1,
+ target_claim: RadrootsTradeMutationId,
+ parent: RadrootsTradeMutationId,
+ ) -> RadrootsTradeMutationEnvelopeV1 {
+ canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 {
+ mutation_id: None,
+ contract_id: radroots_event::trade::RADROOTS_TRADE_CANCELLATION_CONTRACT_ID.to_string(),
+ schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
+ trade_id: trade_id(),
+ root_mutation_id: Some(root_id(root)),
+ buyer_pubkey: pubkey('a'),
+ seller_pubkey: pubkey('b'),
+ farm_id: dtag("farm-1"),
+ parent_mutation_ids: vec![parent],
+ author_pubkey: pubkey('a'),
+ counterparty_pubkey: pubkey('b'),
+ authored_at_unix_s: 300,
+ body: RadrootsTradeMutationBodyV1::Cancellation {
+ target_candidate_id: None,
+ target_claim_mutation_id: Some(target_claim),
+ reason: "before cutoff".to_string(),
},
- 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"),
+ })
+ .unwrap()
+ .envelope
+ }
+
+ fn root_id(envelope: &RadrootsTradeMutationEnvelopeV1) -> RadrootsTradeMutationId {
+ envelope.mutation_id.clone().unwrap()
+ }
+
+ fn record(mutation: RadrootsTradeMutationEnvelopeV1) -> RadrootsTradeMutationRecordV1 {
+ RadrootsTradeMutationRecordV1 {
+ transport_event_id: None,
+ mutation,
}
}
- fn hash32(c: char) -> String {
- format!("0x{}", c.to_string().repeat(64))
+ fn reduce(mutations: Vec<RadrootsTradeMutationEnvelopeV1>) -> RadrootsTradeProjectionV1 {
+ let mut input = RadrootsTradeReductionInputV1::new(trade_id());
+ input.mutations = mutations.into_iter().map(record).collect();
+ reduce_trade_records(input)
}
#[test]
- fn workflow_seller_acceptance_waits_for_rhi_receipt() {
- let mut records = workflow_records();
- records.order_events.decisions.push(accepted_decision());
+ fn reducer_digest_is_independent_of_input_order_and_duplicates() {
+ let proposal = proposal();
+ let decision = accepted_decision(&proposal, '1');
+ let first = reduce(vec![proposal.clone(), decision.clone(), decision.clone()]);
+ let second = reduce(vec![decision, proposal]);
+
+ assert_eq!(first.agreement_state, RadrootsTradeAgreementStateV1::Agreed);
+ assert_eq!(first.projection_digest, second.projection_digest);
+ assert_eq!(first.active_agreement_claim_ids.len(), 1);
+ }
- let projection = reduce_trade_workflow_records(&order_id(), records);
+ #[test]
+ fn reducer_preserves_contested_incompatible_acceptances_without_timestamp_winner() {
+ let proposal = proposal();
+ let first = accepted_decision(&proposal, '1');
+ let second = accepted_decision(&proposal, '2');
+ let projection = reduce(vec![second.clone(), proposal, first.clone()]);
assert_eq!(
- projection.status,
- RadrootsTradeWorkflowState::AgreedPendingValidation
+ projection.agreement_state,
+ RadrootsTradeAgreementStateV1::Contested
);
- 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());
+ assert_eq!(
+ projection.conflict_state,
+ RadrootsTradeConflictStateV1::DoubleAcceptance
+ );
+ assert_eq!(projection.contested_claim_ids, {
+ let mut ids = vec![root_id(&first), root_id(&second)];
+ ids.sort();
+ ids
+ });
}
#[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);
-
- let mut no_listing_expectation = workflow_records();
- no_listing_expectation.expected_listing_event_id = None;
- no_listing_expectation.current_listing_event_id = None;
- no_listing_expectation
- .order_events
- .decisions
- .push(accepted_decision());
- no_listing_expectation
- .validation_receipts
- .push(receipt_record(
- 10,
- RadrootsValidationReceiptResult::Valid,
- event_id(1),
- event_id(2),
- event_id(80),
- ));
- let projection = reduce_trade_workflow_records(&order_id(), no_listing_expectation);
- assert_eq!(projection.status, RadrootsTradeWorkflowState::Committed);
+ fn reducer_resolves_contested_state_only_with_new_causal_accepted_candidate() {
+ let proposal = proposal();
+ let first = accepted_decision(&proposal, '1');
+ let second = accepted_decision(&proposal, '2');
+ let revision = revision_proposal(&proposal, vec![root_id(&first), root_id(&second)]);
+ let revision_acceptance = revision_acceptance(&proposal, &revision);
+ let projection = reduce(vec![
+ second,
+ revision_acceptance.clone(),
+ proposal,
+ first,
+ revision,
+ ]);
+
+ assert_eq!(
+ projection.agreement_state,
+ RadrootsTradeAgreementStateV1::Agreed
+ );
+ assert_eq!(
+ projection.active_agreement_claim_ids,
+ vec![root_id(&revision_acceptance)]
+ );
+ assert!(projection.contested_claim_ids.is_empty());
}
#[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(),
- },
- );
+ fn reducer_keeps_missing_parents_as_incomplete_evidence() {
+ let proposal = proposal();
+ let mut decision = accepted_decision(&proposal, '1');
+ let missing_parent = RadrootsTradeMutationId::parse(hex_64('e')).unwrap();
+ decision.parent_mutation_ids = vec![missing_parent.clone()];
+ let decision = canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 {
+ mutation_id: None,
+ ..decision
+ })
+ .unwrap()
+ .envelope;
+ let projection = reduce(vec![proposal, decision]);
- let projection = reduce_trade_workflow_records(&order_id(), deterministic_failure);
- assert_eq!(projection.status, RadrootsTradeWorkflowState::Invalid);
- assert!(matches!(
- projection.issues.as_slice(),
- [RadrootsOrderIssue::DeterministicValidationFailure { .. }]
- ));
+ assert_eq!(
+ projection.evidence_state,
+ RadrootsTradeEvidenceStateV1::Missing
+ );
+ assert_eq!(projection.missing_parent_ids, vec![missing_parent]);
+ assert_eq!(
+ projection.agreement_state,
+ RadrootsTradeAgreementStateV1::Agreed
+ );
}
#[test]
- fn workflow_cancellation_after_agreement_is_invalid() {
- 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 { .. }]
- ));
+ fn reducer_attestation_never_commits_or_invalidates_agreement() {
+ let proposal = proposal();
+ let decision = accepted_decision(&proposal, '1');
+ let claim_id = root_id(&decision);
+ let mut input = RadrootsTradeReductionInputV1::new(trade_id());
+ input.mutations = vec![record(proposal), record(decision)];
+ input.attestations = vec![RadrootsTradeAttestationRecordV1 {
+ event_id: event_id('9'),
+ claim_mutation_id: claim_id,
+ result: RadrootsTradeAttestationResultV1::Invalid,
+ }];
+ let projection = reduce_trade_records(input);
+
+ assert_eq!(
+ projection.agreement_state,
+ RadrootsTradeAgreementStateV1::Agreed
+ );
+ assert_eq!(
+ projection.attestation_state,
+ RadrootsTradeAttestationStateV1::PresentInvalid
+ );
}
#[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 { .. }]
- ));
+ fn reducer_reports_causally_unordered_cancellation_conflict() {
+ let proposal = proposal();
+ let decision = accepted_decision(&proposal, '1');
+ let cancel = cancellation(&proposal, root_id(&decision), root_id(&proposal));
+ let projection = reduce(vec![proposal, decision, cancel]);
+
+ assert_eq!(
+ projection.agreement_state,
+ RadrootsTradeAgreementStateV1::Contested
+ );
+ assert_eq!(
+ projection.conflict_state,
+ RadrootsTradeConflictStateV1::CancellationConflict
+ );
}
#[test]
- fn workflow_locator_resolution_reports_missing_ambiguous_and_matched_records() {
- let locator = RadrootsTradeLocator::from_order_id(order_id());
+ fn reducer_tracks_private_terms_without_hiding_claims() {
+ let mut root = proposal();
+ if let RadrootsTradeMutationBodyV1::Proposal { candidate } = &mut root.body {
+ candidate.private_terms = Some(RadrootsTradePrivateTermsRefV1 {
+ artifact_id: "artifact-1".to_string(),
+ schema_id: "radroots.private.fulfillment.v1".to_string(),
+ ciphertext_commitment: hex_64('f'),
+ required_acknowledgement: true,
+ });
+ candidate.fulfillment.requires_private_terms = true;
+ }
+ let root = canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 {
+ mutation_id: None,
+ ..root
+ })
+ .unwrap()
+ .envelope;
+ let decision = accepted_decision(&root, '1');
+ let projection = reduce(vec![root, decision]);
- let missing = reduce_trade_workflow_records_for_trade_locator(
- &locator,
- RadrootsTradeWorkflowRecords::default(),
+ assert_eq!(
+ projection.agreement_state,
+ RadrootsTradeAgreementStateV1::Agreed
);
- assert!(matches!(
- missing,
- RadrootsTradeLocatorProjectionResolution::Missing { .. }
- ));
-
- let mut ambiguous = workflow_records();
- let mut second_request = request_record();
- second_request.event_id = event_id(9);
- ambiguous.order_events.requests.push(second_request);
- let ambiguous_resolution =
- reduce_trade_workflow_records_for_trade_locator(&locator, ambiguous);
- assert!(matches!(
- ambiguous_resolution,
- RadrootsTradeLocatorProjectionResolution::Ambiguous { ref candidates, .. }
- if candidates.len() == 2
- ));
-
- let mut matched = workflow_records();
- matched.order_events.decisions.push(accepted_decision());
- matched.validation_receipts.push(receipt_record(
- 9,
- RadrootsValidationReceiptResult::Valid,
- event_id(1),
- event_id(2),
- event_id(80),
- ));
- matched.validation_receipts.push(receipt_record(
- 10,
- RadrootsValidationReceiptResult::Valid,
- event_id(9),
- event_id(2),
- event_id(80),
- ));
- let locator =
- RadrootsTradeLocator::from_order_id(order_id()).with_root_event_id(event_id(1));
- let matched_resolution = reduce_trade_workflow_records_for_trade_locator(&locator, matched);
- assert!(matches!(
- matched_resolution,
- RadrootsTradeLocatorProjectionResolution::Projected { projection, .. }
- if projection.status == RadrootsTradeWorkflowState::Committed
- && projection.validation_receipt_event_id == Some(event_id(9))
- ));
+ assert_eq!(
+ projection.private_terms_state,
+ RadrootsTradePrivateTermsStateV1::Missing
+ );
+ assert_eq!(projection.active_agreement_claim_ids.len(), 1);
}
#[test]
- fn workflow_receipt_binding_reports_each_mismatch_variant() {
- let mut conflicting = workflow_records();
- conflicting.order_events.decisions.push(accepted_decision());
- conflicting.validation_receipts.push(receipt_record(
- 9,
- RadrootsValidationReceiptResult::Valid,
- event_id(1),
- event_id(2),
- event_id(80),
- ));
- conflicting.validation_receipts.push(receipt_record(
- 10,
- RadrootsValidationReceiptResult::Valid,
- event_id(1),
- event_id(2),
- event_id(80),
- ));
- let projection = reduce_trade_workflow_records(&order_id(), conflicting);
- assert!(matches!(
- projection.issues.as_slice(),
- [RadrootsOrderIssue::ConflictingValidationReceipts { .. }]
- ));
-
- let mut no_pending_agreement = workflow_records();
- no_pending_agreement
- .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(), no_pending_agreement);
- assert!(matches!(
- projection.issues.as_slice(),
- [
- RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { .. },
- ..
- ]
- ));
-
- let mut wrong_order = workflow_records();
- wrong_order.order_events.decisions.push(accepted_decision());
- let mut receipt = receipt_record(
- 9,
- RadrootsValidationReceiptResult::Valid,
- event_id(1),
- event_id(2),
- event_id(80),
+ fn reducer_decline_and_expiry_are_negotiation_state_not_agreement_authority() {
+ let proposal = proposal();
+ let declined = declined_decision(&proposal);
+ let declined_projection = reduce(vec![proposal.clone(), declined]);
+ assert_eq!(
+ declined_projection.negotiation_state,
+ RadrootsTradeNegotiationStateV1::ClosedDeclined
);
- receipt.order_id = RadrootsOrderId::parse("order-2").unwrap();
- wrong_order.validation_receipts.push(receipt);
- let projection = reduce_trade_workflow_records(&order_id(), wrong_order);
- assert!(projection.issues.iter().any(|issue| {
- matches!(
- issue,
- RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { .. }
- )
- }));
-
- let mut wrong_order_tag = workflow_records();
- wrong_order_tag
- .order_events
- .decisions
- .push(accepted_decision());
- let mut receipt = receipt_record(
- 10,
- RadrootsValidationReceiptResult::Valid,
- event_id(1),
- event_id(2),
- event_id(80),
+ assert_eq!(
+ declined_projection.agreement_state,
+ RadrootsTradeAgreementStateV1::None
);
- receipt.tags.order_id = "order-2".to_string();
- wrong_order_tag.validation_receipts.push(receipt);
- let projection = reduce_trade_workflow_records(&order_id(), wrong_order_tag);
- assert!(projection.issues.iter().any(|issue| {
- matches!(
- issue,
- RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { .. }
- )
- }));
-
- let mut wrong_type = workflow_records();
- wrong_type.order_events.decisions.push(accepted_decision());
- let mut receipt = receipt_record(
- 9,
- RadrootsValidationReceiptResult::Valid,
- event_id(1),
- event_id(2),
- event_id(80),
+
+ let mut input = RadrootsTradeReductionInputV1::new(trade_id());
+ input.mutations = vec![record(proposal)];
+ input.observed_at_unix_s = Some(1_900_000_000);
+ let expired_projection = reduce_trade_records(input);
+ assert_eq!(
+ expired_projection.negotiation_state,
+ RadrootsTradeNegotiationStateV1::ClosedExpired
);
- receipt.receipt.receipt_type = RadrootsValidationReceiptType::ListingValidation;
- wrong_type.validation_receipts.push(receipt);
- let projection = reduce_trade_workflow_records(&order_id(), wrong_type);
- assert!(projection.issues.iter().any(|issue| {
- matches!(
- issue,
- RadrootsOrderIssue::ValidationReceiptTypeMismatch { .. }
- )
- }));
-
- let mut wrong_type_tag = workflow_records();
- wrong_type_tag
- .order_events
- .decisions
- .push(accepted_decision());
- let mut receipt = receipt_record(
- 10,
- RadrootsValidationReceiptResult::Valid,
- event_id(1),
- event_id(2),
- event_id(80),
+ assert_eq!(
+ expired_projection.agreement_state,
+ RadrootsTradeAgreementStateV1::None
);
- receipt.tags.receipt_type = RadrootsValidationReceiptType::ListingValidation;
- wrong_type_tag.validation_receipts.push(receipt);
- let projection = reduce_trade_workflow_records(&order_id(), wrong_type_tag);
- assert!(projection.issues.iter().any(|issue| {
- matches!(
- issue,
- RadrootsOrderIssue::ValidationReceiptTypeMismatch { .. }
- )
- }));
-
- let mut wrong_root = workflow_records();
- wrong_root.order_events.decisions.push(accepted_decision());
- wrong_root.validation_receipts.push(receipt_record(
- 9,
- RadrootsValidationReceiptResult::Valid,
- event_id(3),
- event_id(2),
- event_id(80),
- ));
- let projection = reduce_trade_workflow_records(&order_id(), wrong_root);
- assert!(projection.issues.iter().any(|issue| {
- matches!(
- issue,
- RadrootsOrderIssue::ValidationReceiptRootMismatch { .. }
- )
- }));
-
- let mut wrong_listing = workflow_records();
- wrong_listing
- .order_events
- .decisions
- .push(accepted_decision());
- wrong_listing.validation_receipts.push(receipt_record(
- 9,
- RadrootsValidationReceiptResult::Valid,
- event_id(1),
- event_id(2),
- event_id(81),
- ));
- let projection = reduce_trade_workflow_records(&order_id(), wrong_listing);
- assert!(projection.issues.iter().any(|issue| {
- matches!(
- issue,
- RadrootsOrderIssue::ValidationReceiptListingMismatch { .. }
- )
- }));
-
- let reservations = inventory_reservations_from_commitments(&[
- RadrootsOrderInventoryCommitment {
- bin_id: bin_id("bin-2"),
- bin_count: 1,
- },
- RadrootsOrderInventoryCommitment {
- bin_id: bin_id("bin-1"),
- bin_count: 1,
- },
- ]);
- assert_eq!(reservations[0].bin_id, bin_id("bin-1"));
}
}