lib

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

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:
Acontracts/conformance/vectors/trade/reduce_records.v1.json | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/operations.toml | 37+++++++++++++++++++++++++++++++++++++
Mcrates/trade/src/dto.rs | 129+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcrates/trade/src/lib.rs | 3---
Mcrates/trade/src/prelude.rs | 3---
Mcrates/trade/src/workflow.rs | 2235+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
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(&registry, "RadrootsTradeWorkflowState"); + let negotiation = find_enum(&registry, "RadrootsTradeNegotiationStateV1"); + let agreement = find_enum(&registry, "RadrootsTradeAgreementStateV1"); + let evidence = find_enum(&registry, "RadrootsTradeEvidenceStateV1"); + let conflict = find_enum(&registry, "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(&registry, "RadrootsOrderWorkflowProjection"); + let projection = find_struct(&registry, "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(&registry, "RadrootsOrderWorkflowProjection"); + let projection = find_struct(&registry, "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")); } }