commit 2c79e6a9b82587a42b3f3de3cfbc4cffaa73eaec
parent d97ff0b2cfce3420d92ca480e701b19b3a7bc04a
Author: triesap <tyson@radroots.org>
Date: Thu, 30 Jul 2026 08:27:33 +0000
trade: define side-effect-free workflow plans
- prepare validated plans for every trade mutation kind
- declare private-term, signing, persistence, and delivery actions
- preserve protocol validation sources across std and no-std builds
- lock plan construction and side-effect boundaries with tests
Diffstat:
4 files changed, 3088 insertions(+), 2561 deletions(-)
diff --git a/crates/trade/src/lib.rs b/crates/trade/src/lib.rs
@@ -11,32 +11,15 @@ pub mod model;
pub mod operational_listing;
pub mod prelude;
pub mod reducer;
-#[path = "workflow.rs"]
+#[path = "reducer_impl.rs"]
mod trade_contract_v1;
pub mod validation;
#[cfg(feature = "serde_json")]
pub mod validation_receipt;
-
-/// Side-effect-free trade workflow contracts.
-///
-/// The versioned reexports are a temporary migration surface for the existing
-/// serialized contract. New reducer consumers should use [`crate::model`],
-/// [`crate::evidence`], and [`crate::reducer`].
-pub mod workflow {
- pub use crate::trade_contract_v1::{
- RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION,
- RadrootsTradeAgreementClaimV1, RadrootsTradeAgreementStateV1,
- RadrootsTradeAttestationRecordV1, RadrootsTradeAttestationResultV1,
- RadrootsTradeAttestationStateV1, RadrootsTradeConflictStateV1,
- RadrootsTradeEvidenceStateV1, RadrootsTradeFulfillmentStateV1,
- RadrootsTradeMutationRecordV1, RadrootsTradeNegotiationStateV1,
- RadrootsTradePaymentStateV1, RadrootsTradePrivateTermsEvidenceV1,
- RadrootsTradePrivateTermsStateV1, RadrootsTradeProjectionV1, RadrootsTradeReducerIssueV1,
- RadrootsTradeReductionInputV1, reduce_trade_records,
- };
-}
+pub mod workflow;
pub use model::RadrootsTradeProjectionV1 as Projection;
pub use reducer::{
RadrootsTradeReducerIssueV1 as ReducerIssue, RadrootsTradeReductionInputV1 as ReductionInput,
};
+pub use workflow::{Error, WorkflowPlan};
diff --git a/crates/trade/src/reducer_impl.rs b/crates/trade/src/reducer_impl.rs
@@ -0,0 +1,2634 @@
+#![forbid(unsafe_code)]
+//! Versioned reducer contract implementation behind the curated public modules.
+
+#[cfg(all(not(feature = "std"), feature = "serde_json"))]
+use alloc::format;
+#[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::{
+ id::{CandidateId, DTag, EventId, MutationId, TradeId},
+ trade::{
+ RADROOTS_TRADE_SCHEMA_VERSION, SellerReservationAssertionV1, TradeCandidateTermsV1,
+ TradeDecisionV1, TradeMutationBodyV1, TradeMutationEnvelopeV1,
+ },
+};
+use radroots_identity::PublicKey;
+#[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")]
+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, 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,
+}
+
+#[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_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 RadrootsTradeReductionInputV1 {
+ trade_id: TradeId,
+ mutations: Vec<RadrootsTradeMutationRecordV1>,
+ private_terms: Vec<RadrootsTradePrivateTermsEvidenceV1>,
+ attestations: Vec<RadrootsTradeAttestationRecordV1>,
+ evidence_state: RadrootsTradeEvidenceStateV1,
+ observed_at_unix_s: Option<u64>,
+}
+
+impl RadrootsTradeReductionInputV1 {
+ pub fn new(trade_id: TradeId) -> Self {
+ Self {
+ trade_id,
+ mutations: Vec::new(),
+ private_terms: Vec::new(),
+ attestations: Vec::new(),
+ evidence_state: RadrootsTradeEvidenceStateV1::Complete,
+ observed_at_unix_s: None,
+ }
+ }
+
+ #[must_use]
+ pub fn with_mutations(mut self, mutations: Vec<RadrootsTradeMutationRecordV1>) -> Self {
+ self.mutations = mutations;
+ self
+ }
+
+ #[must_use]
+ pub fn with_private_terms(
+ mut self,
+ private_terms: Vec<RadrootsTradePrivateTermsEvidenceV1>,
+ ) -> Self {
+ self.private_terms = private_terms;
+ self
+ }
+
+ #[must_use]
+ pub fn with_attestations(
+ mut self,
+ attestations: Vec<RadrootsTradeAttestationRecordV1>,
+ ) -> Self {
+ self.attestations = attestations;
+ self
+ }
+
+ #[must_use]
+ pub fn with_evidence_state(mut self, evidence_state: RadrootsTradeEvidenceStateV1) -> Self {
+ self.evidence_state = evidence_state;
+ self
+ }
+
+ #[must_use]
+ pub fn with_observed_at_unix_s(mut self, observed_at_unix_s: Option<u64>) -> Self {
+ self.observed_at_unix_s = observed_at_unix_s;
+ self
+ }
+
+ pub const fn trade_id(&self) -> &TradeId {
+ &self.trade_id
+ }
+
+ pub fn mutations(&self) -> &[RadrootsTradeMutationRecordV1] {
+ &self.mutations
+ }
+
+ pub fn private_terms(&self) -> &[RadrootsTradePrivateTermsEvidenceV1] {
+ &self.private_terms
+ }
+
+ pub fn attestations(&self) -> &[RadrootsTradeAttestationRecordV1] {
+ &self.attestations
+ }
+
+ pub const fn evidence_state(&self) -> RadrootsTradeEvidenceStateV1 {
+ self.evidence_state
+ }
+
+ pub const fn observed_at_unix_s(&self) -> Option<u64> {
+ self.observed_at_unix_s
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeMutationRecordV1 {
+ transport_event_id: Option<EventId>,
+ mutation: TradeMutationEnvelopeV1,
+}
+
+impl RadrootsTradeMutationRecordV1 {
+ pub const fn new(
+ transport_event_id: Option<EventId>,
+ mutation: TradeMutationEnvelopeV1,
+ ) -> Self {
+ Self {
+ transport_event_id,
+ mutation,
+ }
+ }
+
+ pub const fn transport_event_id(&self) -> Option<&EventId> {
+ self.transport_event_id.as_ref()
+ }
+
+ pub const fn mutation(&self) -> &TradeMutationEnvelopeV1 {
+ &self.mutation
+ }
+}
+
+#[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 {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ candidate_id: CandidateId,
+ state: RadrootsTradePrivateTermsStateV1,
+}
+
+impl RadrootsTradePrivateTermsEvidenceV1 {
+ pub const fn new(candidate_id: CandidateId, state: RadrootsTradePrivateTermsStateV1) -> Self {
+ Self {
+ candidate_id,
+ state,
+ }
+ }
+
+ pub const fn candidate_id(&self) -> &CandidateId {
+ &self.candidate_id
+ }
+
+ pub const fn state(&self) -> RadrootsTradePrivateTermsStateV1 {
+ self.state
+ }
+}
+
+#[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, PartialOrd, Ord)]
+pub struct RadrootsTradeAttestationRecordV1 {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ event_id: EventId,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ claim_mutation_id: MutationId,
+ result: RadrootsTradeAttestationResultV1,
+}
+
+impl RadrootsTradeAttestationRecordV1 {
+ pub const fn new(
+ event_id: EventId,
+ claim_mutation_id: MutationId,
+ result: RadrootsTradeAttestationResultV1,
+ ) -> Self {
+ Self {
+ event_id,
+ claim_mutation_id,
+ result,
+ }
+ }
+
+ pub const fn event_id(&self) -> &EventId {
+ &self.event_id
+ }
+
+ pub const fn claim_mutation_id(&self) -> &MutationId {
+ &self.claim_mutation_id
+ }
+
+ pub const fn result(&self) -> RadrootsTradeAttestationResultV1 {
+ self.result
+ }
+}
+
+#[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 {
+ reducer_contract_id: String,
+ reducer_version: u16,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ trade_id: TradeId,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ root_mutation_id: Option<MutationId>,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ buyer_pubkey: Option<PublicKey>,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ seller_pubkey: Option<PublicKey>,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ farm_id: Option<DTag>,
+ negotiation_state: RadrootsTradeNegotiationStateV1,
+ agreement_state: RadrootsTradeAgreementStateV1,
+ evidence_state: RadrootsTradeEvidenceStateV1,
+ conflict_state: RadrootsTradeConflictStateV1,
+ private_terms_state: RadrootsTradePrivateTermsStateV1,
+ attestation_state: RadrootsTradeAttestationStateV1,
+ fulfillment_state: RadrootsTradeFulfillmentStateV1,
+ payment_state: RadrootsTradePaymentStateV1,
+ #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
+ candidate_heads: Vec<MutationId>,
+ agreement_claims: Vec<RadrootsTradeAgreementClaimV1>,
+ #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
+ active_agreement_claim_ids: Vec<MutationId>,
+ #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
+ contested_claim_ids: Vec<MutationId>,
+ #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
+ cancelled_claim_ids: Vec<MutationId>,
+ #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
+ declined_candidate_ids: Vec<CandidateId>,
+ #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
+ missing_parent_ids: Vec<MutationId>,
+ #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
+ missing_proposal_ids: Vec<MutationId>,
+ #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
+ unsupported_mutation_ids: Vec<MutationId>,
+ issues: Vec<RadrootsTradeReducerIssueV1>,
+ attestations: Vec<RadrootsTradeAttestationRecordV1>,
+ projection_digest: String,
+}
+
+impl RadrootsTradeProjectionV1 {
+ fn empty(trade_id: TradeId) -> 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(),
+ }
+ }
+
+ 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_key(|left| left.claim_mutation_id);
+ self.issues.sort();
+ self.issues.dedup();
+ self.attestations.sort();
+ self.attestations.dedup();
+ match projection_digest(self) {
+ Ok(digest) => self.projection_digest = digest,
+ Err(reason) => {
+ self.projection_digest.clear();
+ self.issues
+ .push(RadrootsTradeReducerIssueV1::ProjectionDigestUnavailable { reason });
+ self.issues.sort();
+ self.issues.dedup();
+ }
+ }
+ }
+
+ pub fn reducer_contract_id(&self) -> &str {
+ &self.reducer_contract_id
+ }
+
+ pub const fn reducer_version(&self) -> u16 {
+ self.reducer_version
+ }
+
+ pub const fn trade_id(&self) -> &TradeId {
+ &self.trade_id
+ }
+
+ pub const fn root_mutation_id(&self) -> Option<&MutationId> {
+ self.root_mutation_id.as_ref()
+ }
+
+ pub const fn buyer_pubkey(&self) -> Option<&PublicKey> {
+ self.buyer_pubkey.as_ref()
+ }
+
+ pub const fn seller_pubkey(&self) -> Option<&PublicKey> {
+ self.seller_pubkey.as_ref()
+ }
+
+ pub const fn farm_id(&self) -> Option<&DTag> {
+ self.farm_id.as_ref()
+ }
+
+ pub const fn negotiation_state(&self) -> RadrootsTradeNegotiationStateV1 {
+ self.negotiation_state
+ }
+
+ pub const fn agreement_state(&self) -> RadrootsTradeAgreementStateV1 {
+ self.agreement_state
+ }
+
+ pub const fn evidence_state(&self) -> RadrootsTradeEvidenceStateV1 {
+ self.evidence_state
+ }
+
+ pub const fn conflict_state(&self) -> RadrootsTradeConflictStateV1 {
+ self.conflict_state
+ }
+
+ pub const fn private_terms_state(&self) -> RadrootsTradePrivateTermsStateV1 {
+ self.private_terms_state
+ }
+
+ pub const fn attestation_state(&self) -> RadrootsTradeAttestationStateV1 {
+ self.attestation_state
+ }
+
+ pub const fn fulfillment_state(&self) -> RadrootsTradeFulfillmentStateV1 {
+ self.fulfillment_state
+ }
+
+ pub const fn payment_state(&self) -> RadrootsTradePaymentStateV1 {
+ self.payment_state
+ }
+
+ pub fn candidate_heads(&self) -> &[MutationId] {
+ &self.candidate_heads
+ }
+
+ pub fn agreement_claims(&self) -> &[RadrootsTradeAgreementClaimV1] {
+ &self.agreement_claims
+ }
+
+ pub fn active_agreement_claim_ids(&self) -> &[MutationId] {
+ &self.active_agreement_claim_ids
+ }
+
+ pub fn contested_claim_ids(&self) -> &[MutationId] {
+ &self.contested_claim_ids
+ }
+
+ pub fn cancelled_claim_ids(&self) -> &[MutationId] {
+ &self.cancelled_claim_ids
+ }
+
+ pub fn declined_candidate_ids(&self) -> &[CandidateId] {
+ &self.declined_candidate_ids
+ }
+
+ pub fn missing_parent_ids(&self) -> &[MutationId] {
+ &self.missing_parent_ids
+ }
+
+ pub fn missing_proposal_ids(&self) -> &[MutationId] {
+ &self.missing_proposal_ids
+ }
+
+ pub fn unsupported_mutation_ids(&self) -> &[MutationId] {
+ &self.unsupported_mutation_ids
+ }
+
+ pub fn issues(&self) -> &[RadrootsTradeReducerIssueV1] {
+ &self.issues
+ }
+
+ pub fn attestations(&self) -> &[RadrootsTradeAttestationRecordV1] {
+ &self.attestations
+ }
+
+ pub fn projection_digest(&self) -> &str {
+ &self.projection_digest
+ }
+}
+
+#[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 {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ claim_mutation_id: MutationId,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ proposal_mutation_id: MutationId,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ candidate_id: CandidateId,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ candidate_author_pubkey: PublicKey,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ accepted_by_pubkey: PublicKey,
+ reservation_commitment: String,
+}
+
+impl RadrootsTradeAgreementClaimV1 {
+ pub const fn claim_mutation_id(&self) -> &MutationId {
+ &self.claim_mutation_id
+ }
+
+ pub const fn proposal_mutation_id(&self) -> &MutationId {
+ &self.proposal_mutation_id
+ }
+
+ pub const fn candidate_id(&self) -> &CandidateId {
+ &self.candidate_id
+ }
+
+ pub const fn candidate_author_pubkey(&self) -> &PublicKey {
+ &self.candidate_author_pubkey
+ }
+
+ pub const fn accepted_by_pubkey(&self) -> &PublicKey {
+ &self.accepted_by_pubkey
+ }
+
+ pub fn reservation_commitment(&self) -> &str {
+ &self.reservation_commitment
+ }
+}
+
+#[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 {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ mutation_id: MutationId,
+ },
+ UnsupportedSchema {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ mutation_id: MutationId,
+ schema_version: u16,
+ },
+ InvalidMutation {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ mutation_id: Option<MutationId>,
+ reason: String,
+ },
+ MissingParent {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ mutation_id: MutationId,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ parent_mutation_id: MutationId,
+ },
+ MissingProposal {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ decision_mutation_id: MutationId,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ proposal_mutation_id: MutationId,
+ },
+ CandidateIdMismatch {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ decision_mutation_id: MutationId,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ proposal_mutation_id: MutationId,
+ },
+ DecisionAuthorMismatch {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ decision_mutation_id: MutationId,
+ },
+ DecisionParentMissing {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ decision_mutation_id: MutationId,
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ proposal_mutation_id: MutationId,
+ },
+ MissingSellerReservation {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ decision_mutation_id: MutationId,
+ },
+ ReservationCandidateMismatch {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ decision_mutation_id: MutationId,
+ },
+ ReservationAuthorityMismatch {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ decision_mutation_id: MutationId,
+ },
+ ReservationLineMismatch {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ decision_mutation_id: MutationId,
+ },
+ DecisionConflict {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ proposal_mutation_id: MutationId,
+ },
+ DoubleAcceptance {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ proposal_mutation_id: MutationId,
+ },
+ CancellationConflict {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ cancellation_mutation_id: MutationId,
+ },
+ InvalidCausalChain {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ mutation_id: MutationId,
+ },
+ PrivateTermsUnavailable {
+ #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+ candidate_id: CandidateId,
+ },
+ ProjectionDigestUnavailable {
+ reason: String,
+ },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct CandidateRecord {
+ proposal_mutation_id: MutationId,
+ author_pubkey: PublicKey,
+ candidate: TradeCandidateTermsV1,
+}
+
+struct DecisionApplication<'a> {
+ mutation_id: &'a MutationId,
+ mutation: &'a TradeMutationEnvelopeV1,
+ proposal_mutation_id: &'a MutationId,
+ candidate_id: &'a CandidateId,
+ decision: &'a TradeDecisionV1,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct CancellationRecord {
+ mutation_id: MutationId,
+ parent_mutation_ids: Vec<MutationId>,
+ target_candidate_id: Option<CandidateId>,
+ target_claim_mutation_id: Option<MutationId>,
+}
+
+pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTradeProjectionV1 {
+ let mut projection = RadrootsTradeProjectionV1::empty(input.trade_id);
+ let mut mutations = BTreeMap::<MutationId, TradeMutationEnvelopeV1>::new();
+
+ for record in input.mutations {
+ let mutation_id = match record.mutation.mutation_id {
+ 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);
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::UnsupportedSchema {
+ mutation_id,
+ schema_version: record.mutation.schema_version,
+ });
+ continue;
+ }
+ if let Err(error) = record.mutation.validate() {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::InvalidMutation {
+ mutation_id: Some(mutation_id),
+ reason: error.to_string(),
+ });
+ continue;
+ }
+ if record.mutation.trade_id != input.trade_id {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::TradeIdentityMismatch { mutation_id });
+ continue;
+ }
+ mutations.entry(mutation_id).or_insert(record.mutation);
+ }
+
+ let mut root_proposals = Vec::<MutationId>::new();
+ let mut candidates_by_proposal = BTreeMap::<MutationId, CandidateRecord>::new();
+ let mut claims = BTreeMap::<MutationId, RadrootsTradeAgreementClaimV1>::new();
+ let mut decisions_by_proposal = BTreeMap::<MutationId, Vec<MutationId>>::new();
+ let mut cancellations = Vec::<CancellationRecord>::new();
+ let mut referenced_parents = BTreeSet::<MutationId>::new();
+
+ for (mutation_id, mutation) in &mutations {
+ if matches!(mutation.body, TradeMutationBodyV1::Proposal { .. }) {
+ root_proposals.push(*mutation_id);
+ }
+ for parent in &mutation.parent_mutation_ids {
+ referenced_parents.insert(*parent);
+ if !mutations.contains_key(parent) {
+ projection.missing_parent_ids.push(*parent);
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::MissingParent {
+ mutation_id: *mutation_id,
+ parent_mutation_id: *parent,
+ });
+ }
+ }
+ match &mutation.body {
+ TradeMutationBodyV1::Proposal { candidate }
+ | TradeMutationBodyV1::RevisionProposal { candidate } => {
+ candidates_by_proposal.insert(
+ *mutation_id,
+ CandidateRecord {
+ proposal_mutation_id: *mutation_id,
+ author_pubkey: mutation.author_pubkey,
+ candidate: candidate.clone(),
+ },
+ );
+ }
+ TradeMutationBodyV1::Decision { .. } | TradeMutationBodyV1::RevisionDecision { .. } => {
+ }
+ TradeMutationBodyV1::Cancellation {
+ target_candidate_id,
+ target_claim_mutation_id,
+ reason: _,
+ } => cancellations.push(CancellationRecord {
+ mutation_id: *mutation_id,
+ parent_mutation_ids: mutation.parent_mutation_ids.clone(),
+ target_candidate_id: *target_candidate_id,
+ target_claim_mutation_id: *target_claim_mutation_id,
+ }),
+ }
+ }
+
+ for (mutation_id, mutation) in &mutations {
+ match &mutation.body {
+ TradeMutationBodyV1::Decision {
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ }
+ | TradeMutationBodyV1::RevisionDecision {
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ } => {
+ decisions_by_proposal
+ .entry(*proposal_mutation_id)
+ .or_default()
+ .push(*mutation_id);
+ apply_decision(
+ DecisionApplication {
+ mutation_id,
+ mutation,
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ },
+ &candidates_by_proposal,
+ &mut claims,
+ &mut projection,
+ );
+ }
+ _ => {}
+ }
+ }
+
+ if root_proposals.is_empty() {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::MissingRootProposal);
+ } else if root_proposals.len() > 1 {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::MultipleRootProposals);
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::ConcurrentCandidates,
+ );
+ } else {
+ projection.root_mutation_id = root_proposals.first().cloned();
+ let root = projection
+ .root_mutation_id
+ .as_ref()
+ .and_then(|root_id| mutations.get(root_id))
+ .expect("root proposal selected from the validated mutation map");
+ projection.buyer_pubkey = Some(root.buyer_pubkey);
+ projection.seller_pubkey = Some(root.seller_pubkey);
+ projection.farm_id = Some(root.farm_id.clone());
+ }
+
+ for (proposal_mutation_id, decision_ids) in &decisions_by_proposal {
+ let unique: BTreeSet<MutationId> = 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,
+ });
+ 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,
+ });
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::DecisionConflict,
+ );
+ }
+ }
+ }
+
+ 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 {
+ let claim = claims
+ .get(claim_id)
+ .expect("active agreement identifiers originate from indexed claims");
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::PrivateTermsUnavailable {
+ candidate_id: claim.candidate_id,
+ });
+ }
+ }
+ 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
+}
+
+fn apply_decision(
+ application: DecisionApplication<'_>,
+ candidates_by_proposal: &BTreeMap<MutationId, CandidateRecord>,
+ claims: &mut BTreeMap<MutationId, RadrootsTradeAgreementClaimV1>,
+ projection: &mut RadrootsTradeProjectionV1,
+) {
+ let DecisionApplication {
+ mutation_id,
+ mutation,
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ } = application;
+ let Some(candidate_record) = candidates_by_proposal.get(proposal_mutation_id) else {
+ projection.missing_proposal_ids.push(*proposal_mutation_id);
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::MissingProposal {
+ decision_mutation_id: *mutation_id,
+ proposal_mutation_id: *proposal_mutation_id,
+ });
+ return;
+ };
+ if candidate_record.candidate.candidate_id.as_ref() != Some(candidate_id) {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::CandidateIdMismatch {
+ decision_mutation_id: *mutation_id,
+ proposal_mutation_id: *proposal_mutation_id,
+ });
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::DecisionConflict,
+ );
+ return;
+ }
+ if mutation.author_pubkey != candidate_record_author_counterparty(candidate_record, mutation) {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::DecisionAuthorMismatch {
+ decision_mutation_id: *mutation_id,
+ });
+ return;
+ }
+ if !mutation.parent_mutation_ids.contains(proposal_mutation_id) {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::DecisionParentMissing {
+ decision_mutation_id: *mutation_id,
+ proposal_mutation_id: *proposal_mutation_id,
+ });
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::InvalidCausalChain,
+ );
+ }
+ match decision {
+ TradeDecisionV1::Accepted {
+ reservation_assertion,
+ } => {
+ let Some(reservation) = reservation_assertion else {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::MissingSellerReservation {
+ decision_mutation_id: *mutation_id,
+ });
+ return;
+ };
+ if validate_reservation(
+ mutation_id,
+ candidate_id,
+ &candidate_record.candidate,
+ reservation,
+ projection,
+ ) {
+ claims.insert(
+ *mutation_id,
+ RadrootsTradeAgreementClaimV1 {
+ claim_mutation_id: *mutation_id,
+ proposal_mutation_id: candidate_record.proposal_mutation_id,
+ candidate_id: *candidate_id,
+ candidate_author_pubkey: candidate_record.author_pubkey,
+ accepted_by_pubkey: mutation.author_pubkey,
+ reservation_commitment: reservation.assertion_commitment.clone(),
+ },
+ );
+ }
+ }
+ TradeDecisionV1::Declined { .. } => {}
+ }
+}
+
+fn candidate_record_author_counterparty(
+ candidate_record: &CandidateRecord,
+ mutation: &TradeMutationEnvelopeV1,
+) -> PublicKey {
+ if candidate_record.author_pubkey == mutation.buyer_pubkey {
+ mutation.seller_pubkey
+ } else {
+ mutation.buyer_pubkey
+ }
+}
+
+fn validate_reservation(
+ decision_mutation_id: &MutationId,
+ candidate_id: &CandidateId,
+ candidate: &TradeCandidateTermsV1,
+ reservation: &SellerReservationAssertionV1,
+ projection: &mut RadrootsTradeProjectionV1,
+) -> bool {
+ let mut valid = true;
+ if &reservation.candidate_id != candidate_id {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::ReservationCandidateMismatch {
+ decision_mutation_id: *decision_mutation_id,
+ });
+ valid = false;
+ }
+ if reservation.inventory_authority_id != candidate.seller_pubkey {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::ReservationAuthorityMismatch {
+ decision_mutation_id: *decision_mutation_id,
+ });
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::InventoryAuthorityConflict,
+ );
+ valid = false;
+ }
+ if reservation.commitments.len() != candidate.lines.len() {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::ReservationLineMismatch {
+ decision_mutation_id: *decision_mutation_id,
+ });
+ 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,
+ });
+ valid = false;
+ break;
+ }
+ }
+ valid
+}
+
+fn apply_agreement_state(
+ projection: &mut RadrootsTradeProjectionV1,
+ claims: &BTreeMap<MutationId, RadrootsTradeAgreementClaimV1>,
+ mutations: &BTreeMap<MutationId, TradeMutationEnvelopeV1>,
+ candidates_by_proposal: &BTreeMap<MutationId, 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);
+ if projection.active_agreement_claim_ids == [*target_claim_id] {
+ projection.agreement_state = RadrootsTradeAgreementStateV1::Cancelled;
+ }
+ } else {
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::CancellationConflict {
+ cancellation_mutation_id: cancellation.mutation_id,
+ });
+ projection.agreement_state = RadrootsTradeAgreementStateV1::Contested;
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::CancellationConflict,
+ );
+ }
+ }
+ }
+}
+
+fn cancellation_without_claim(
+ cancellations: &[CancellationRecord],
+ candidates_by_proposal: &BTreeMap<MutationId, 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
+ })
+ })
+ })
+}
+
+fn non_dominated_claim_ids(
+ claims: &BTreeMap<MutationId, RadrootsTradeAgreementClaimV1>,
+ mutations: &BTreeMap<MutationId, TradeMutationEnvelopeV1>,
+) -> Vec<MutationId> {
+ let mut memo = BTreeMap::<MutationId, BTreeSet<MutationId>>::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()
+}
+
+fn compatible_claims(claims: &[&RadrootsTradeAgreementClaimV1]) -> bool {
+ let first = claims
+ .first()
+ .expect("non-empty claims produce at least one non-dominated claim");
+ claims.iter().all(|claim| {
+ claim.candidate_id == first.candidate_id
+ && claim.reservation_commitment == first.reservation_commitment
+ })
+}
+
+fn ancestors_of(
+ mutation_id: &MutationId,
+ mutations: &BTreeMap<MutationId, TradeMutationEnvelopeV1>,
+ memo: &mut BTreeMap<MutationId, BTreeSet<MutationId>>,
+) -> BTreeSet<MutationId> {
+ 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);
+ ancestors.extend(ancestors_of(parent, mutations, memo));
+ }
+ }
+ memo.insert(*mutation_id, ancestors.clone());
+ ancestors
+}
+
+fn apply_negotiation_state(
+ projection: &mut RadrootsTradeProjectionV1,
+ candidates_by_proposal: &BTreeMap<MutationId, CandidateRecord>,
+ claims: &BTreeMap<MutationId, 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<MutationId, CandidateRecord>,
+ private_terms: &[RadrootsTradePrivateTermsEvidenceV1],
+) -> RadrootsTradePrivateTermsStateV1 {
+ let mut private_terms_by_candidate =
+ BTreeMap::<CandidateId, RadrootsTradePrivateTermsStateV1>::new();
+ for record in private_terms {
+ private_terms_by_candidate
+ .entry(record.candidate_id)
+ .and_modify(|state| *state = (*state).max(record.state))
+ .or_insert(record.state);
+ }
+ let mut required_states = Vec::new();
+ for claim in &projection.agreement_claims {
+ let candidate_record = candidates_by_proposal
+ .get(&claim.proposal_mutation_id)
+ .expect("agreement claims originate from indexed candidates");
+ 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.contains(&RadrootsTradePrivateTermsStateV1::CommitmentMismatch) {
+ RadrootsTradePrivateTermsStateV1::CommitmentMismatch
+ } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::Undecryptable) {
+ RadrootsTradePrivateTermsStateV1::Undecryptable
+ } else if required_states.contains(&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<MutationId, TradeMutationEnvelopeV1>,
+) -> Vec<CandidateId> {
+ mutations
+ .values()
+ .filter_map(|mutation| match &mutation.body {
+ TradeMutationBodyV1::Decision {
+ candidate_id,
+ decision: TradeDecisionV1::Declined { .. },
+ ..
+ }
+ | TradeMutationBodyV1::RevisionDecision {
+ candidate_id,
+ decision: TradeDecisionV1::Declined { .. },
+ ..
+ } => Some(*candidate_id),
+ _ => None,
+ })
+ .collect()
+}
+
+fn is_acceptance(body: &TradeMutationBodyV1) -> bool {
+ matches!(
+ body,
+ TradeMutationBodyV1::Decision {
+ decision: TradeDecisionV1::Accepted { .. },
+ ..
+ } | TradeMutationBodyV1::RevisionDecision {
+ decision: TradeDecisionV1::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) -> Result<String, String> {
+ let mut digest_input = projection.clone();
+ digest_input.projection_digest.clear();
+ let value = serde_json::to_value(&digest_input)
+ .map_err(|error| format!("projection serialization failed: {error}"))?;
+ let canonical = radroots_event::trade::canonical_jcs_value(&value)
+ .map_err(|error| format!("projection canonicalization failed: {error}"))?;
+ let mut hasher = Sha256::new();
+ hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN);
+ hasher.update(canonical.as_bytes());
+ Ok(hex::encode(hasher.finalize()))
+}
+
+#[cfg(not(feature = "serde_json"))]
+fn projection_digest(_projection: &RadrootsTradeProjectionV1) -> Result<String, String> {
+ Err("projection digest requires the serde_json feature".to_string())
+}
+
+#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+mod tests {
+ use super::*;
+ use radroots_event::{
+ id::{ClassifiedListingAddress, InventoryBinId},
+ trade::{
+ FulfillmentProfileV1, RADROOTS_TRADE_DECISION_CONTRACT_ID,
+ RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID,
+ RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, SellerReservationLineV1,
+ TradeCancellationProfileV1, TradeCandidateLineV1, TradeEconomicAdjustmentV1,
+ TradeEconomicsProfileV1, TradeLineTombstoneV1, TradePrivateTermsRefV1,
+ canonical_trade_mutation_content,
+ },
+ };
+ use radroots_test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_BOB_PUBLIC_KEY_HEX};
+
+ const REDUCER_VECTORS: &str =
+ include_str!("../../../contracts/conformance/vectors/trade/reduce_records.v1.json");
+
+ fn hex_64(character: char) -> String {
+ core::iter::repeat_n(character, 64).collect()
+ }
+
+ fn hex_32(character: char) -> String {
+ core::iter::repeat_n(character, 32).collect()
+ }
+
+ fn pubkey(character: char) -> PublicKey {
+ let public_key_hex = match character {
+ 'a' => FIXTURE_ALICE_PUBLIC_KEY_HEX,
+ 'b' => FIXTURE_BOB_PUBLIC_KEY_HEX,
+ _ => panic!("unsupported fixture public key label: {character}"),
+ };
+ PublicKey::from_hex(public_key_hex).expect("fixture pubkey")
+ }
+
+ fn event_id(character: char) -> EventId {
+ EventId::parse(hex_64(character)).unwrap()
+ }
+
+ fn trade_id() -> TradeId {
+ TradeId::parse(hex_32('1')).unwrap()
+ }
+
+ fn dtag(value: &str) -> DTag {
+ DTag::parse(value).unwrap()
+ }
+
+ fn bin_id(value: &str) -> InventoryBinId {
+ InventoryBinId::parse(value).unwrap()
+ }
+
+ fn candidate(line_suffix: &str) -> TradeCandidateTermsV1 {
+ TradeCandidateTermsV1 {
+ 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![TradeCandidateLineV1 {
+ line_id: dtag(&format!("line-{line_suffix}")),
+ listing_addr: ClassifiedListingAddress::parse(format!(
+ "30402:{}:listing-{line_suffix}",
+ FIXTURE_BOB_PUBLIC_KEY_HEX
+ ))
+ .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,
+ }],
+ line_tombstones: Vec::<TradeLineTombstoneV1>::new(),
+ economics: TradeEconomicsProfileV1 {
+ 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::<TradeEconomicAdjustmentV1>::new(),
+ },
+ fulfillment: FulfillmentProfileV1 {
+ 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: TradeCancellationProfileV1 {
+ 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 proposal() -> TradeMutationEnvelopeV1 {
+ canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
+ 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: TradeMutationBodyV1::Proposal {
+ candidate: candidate("1"),
+ },
+ })
+ .unwrap()
+ .envelope
+ }
+
+ fn reservation(
+ candidate: &TradeCandidateTermsV1,
+ marker: char,
+ ) -> SellerReservationAssertionV1 {
+ SellerReservationAssertionV1 {
+ reservation_id: dtag(&format!("reservation-{marker}")),
+ inventory_authority_id: candidate.seller_pubkey,
+ inventory_epoch: 42,
+ candidate_id: candidate.candidate_id.unwrap(),
+ commitments: candidate
+ .lines
+ .iter()
+ .map(|line| SellerReservationLineV1 {
+ 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(
+ proposal: &TradeMutationEnvelopeV1,
+ marker: char,
+ ) -> TradeMutationEnvelopeV1 {
+ let proposal_id = proposal.mutation_id.unwrap();
+ let candidate = match &proposal.body {
+ TradeMutationBodyV1::Proposal { candidate }
+ | TradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(),
+ _ => unreachable!(),
+ };
+ canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
+ 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],
+ author_pubkey: pubkey('b'),
+ counterparty_pubkey: pubkey('a'),
+ authored_at_unix_s: u64::from(marker),
+ body: TradeMutationBodyV1::Decision {
+ proposal_mutation_id: proposal_id,
+ candidate_id: candidate.candidate_id.unwrap(),
+ decision: TradeDecisionV1::Accepted {
+ reservation_assertion: Some(reservation(&candidate, marker)),
+ },
+ },
+ })
+ .unwrap()
+ .envelope
+ }
+
+ fn declined_decision(proposal: &TradeMutationEnvelopeV1) -> TradeMutationEnvelopeV1 {
+ let proposal_id = proposal.mutation_id.unwrap();
+ let candidate = match &proposal.body {
+ TradeMutationBodyV1::Proposal { candidate }
+ | TradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(),
+ _ => unreachable!(),
+ };
+ canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
+ 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],
+ author_pubkey: pubkey('b'),
+ counterparty_pubkey: pubkey('a'),
+ authored_at_unix_s: 102,
+ body: TradeMutationBodyV1::Decision {
+ proposal_mutation_id: proposal_id,
+ candidate_id: candidate.candidate_id.unwrap(),
+ decision: TradeDecisionV1::Declined {
+ reason: "unavailable".to_string(),
+ },
+ },
+ })
+ .unwrap()
+ .envelope
+ }
+
+ fn revision_proposal(
+ root: &TradeMutationEnvelopeV1,
+ parents: Vec<MutationId>,
+ ) -> TradeMutationEnvelopeV1 {
+ let mut parents = parents;
+ parents.sort();
+ canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
+ 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: TradeMutationBodyV1::RevisionProposal {
+ candidate: candidate("2"),
+ },
+ })
+ .unwrap()
+ .envelope
+ }
+
+ fn revision_acceptance(
+ root: &TradeMutationEnvelopeV1,
+ proposal: &TradeMutationEnvelopeV1,
+ ) -> TradeMutationEnvelopeV1 {
+ let proposal_id = proposal.mutation_id.unwrap();
+ let candidate = match &proposal.body {
+ TradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(),
+ _ => unreachable!(),
+ };
+ canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
+ 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],
+ author_pubkey: pubkey('b'),
+ counterparty_pubkey: pubkey('a'),
+ authored_at_unix_s: 201,
+ body: TradeMutationBodyV1::RevisionDecision {
+ proposal_mutation_id: proposal_id,
+ candidate_id: candidate.candidate_id.unwrap(),
+ decision: TradeDecisionV1::Accepted {
+ reservation_assertion: Some(reservation(&candidate, '9')),
+ },
+ },
+ })
+ .unwrap()
+ .envelope
+ }
+
+ fn cancellation(
+ root: &TradeMutationEnvelopeV1,
+ target_claim: MutationId,
+ parent: MutationId,
+ ) -> TradeMutationEnvelopeV1 {
+ canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
+ 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: TradeMutationBodyV1::Cancellation {
+ target_candidate_id: None,
+ target_claim_mutation_id: Some(target_claim),
+ reason: "before cutoff".to_string(),
+ },
+ })
+ .unwrap()
+ .envelope
+ }
+
+ fn root_id(envelope: &TradeMutationEnvelopeV1) -> MutationId {
+ envelope.mutation_id.unwrap()
+ }
+
+ fn record(mutation: TradeMutationEnvelopeV1) -> RadrootsTradeMutationRecordV1 {
+ RadrootsTradeMutationRecordV1 {
+ transport_event_id: None,
+ mutation,
+ }
+ }
+
+ fn reduce(mutations: Vec<TradeMutationEnvelopeV1>) -> RadrootsTradeProjectionV1 {
+ let mut input = RadrootsTradeReductionInputV1::new(trade_id());
+ input.mutations = mutations.into_iter().map(record).collect();
+ reduce_trade_records(input)
+ }
+
+ fn recanonicalize(mut mutation: TradeMutationEnvelopeV1) -> TradeMutationEnvelopeV1 {
+ mutation.mutation_id = None;
+ canonical_trade_mutation_content(mutation)
+ .expect("recanonicalized mutation")
+ .envelope
+ }
+
+ fn candidate_cancellation(root: &TradeMutationEnvelopeV1) -> TradeMutationEnvelopeV1 {
+ let candidate_id = match &root.body {
+ TradeMutationBodyV1::Proposal { candidate } => {
+ candidate.candidate_id.expect("candidate id")
+ }
+ _ => unreachable!(),
+ };
+ canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
+ 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![root_id(root)],
+ author_pubkey: pubkey('a'),
+ counterparty_pubkey: pubkey('b'),
+ authored_at_unix_s: 301,
+ body: TradeMutationBodyV1::Cancellation {
+ target_candidate_id: Some(candidate_id),
+ target_claim_mutation_id: None,
+ reason: "cancel before agreement".to_string(),
+ },
+ })
+ .expect("candidate cancellation")
+ .envelope
+ }
+
+ #[test]
+ 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,
+ "35a5f555344febe675f4e5e6b15865356400b2b8f7791fc631590e0f1e1fd441"
+ );
+ assert_eq!(first.projection_digest, second.projection_digest);
+ assert_eq!(first.active_agreement_claim_ids.len(), 1);
+ }
+
+ #[test]
+ fn reducer_projection_is_identical_for_every_three_record_permutation() {
+ let proposal = proposal();
+ let decision = accepted_decision(&proposal, '1');
+ let cancellation = cancellation(&proposal, root_id(&decision), root_id(&decision));
+ let permutations = [
+ vec![proposal.clone(), decision.clone(), cancellation.clone()],
+ vec![proposal.clone(), cancellation.clone(), decision.clone()],
+ vec![decision.clone(), proposal.clone(), cancellation.clone()],
+ vec![decision.clone(), cancellation.clone(), proposal.clone()],
+ vec![cancellation.clone(), proposal.clone(), decision.clone()],
+ vec![cancellation, decision, proposal],
+ ];
+ let expected = reduce(permutations[0].clone());
+
+ for permutation in permutations.into_iter().skip(1) {
+ assert_eq!(reduce(permutation), expected);
+ }
+ }
+
+ #[test]
+ fn reducer_excludes_unsupported_versions_from_domain_semantics() {
+ let mut unsupported = proposal();
+ unsupported.schema_version += 1;
+ let unsupported_id = root_id(&unsupported);
+
+ let projection = reduce(vec![unsupported]);
+
+ assert_eq!(
+ projection.evidence_state,
+ RadrootsTradeEvidenceStateV1::UnsupportedVersion
+ );
+ assert_eq!(
+ projection.negotiation_state,
+ RadrootsTradeNegotiationStateV1::None
+ );
+ assert_eq!(
+ projection.agreement_state,
+ RadrootsTradeAgreementStateV1::None
+ );
+ assert_eq!(projection.root_mutation_id, None);
+ assert_eq!(projection.unsupported_mutation_ids, vec![unsupported_id]);
+ }
+
+ #[test]
+ fn reducer_private_evidence_precedence_is_permutation_independent() {
+ let mut root = proposal();
+ if let TradeMutationBodyV1::Proposal { candidate } = &mut root.body {
+ candidate.private_terms = Some(TradePrivateTermsRefV1 {
+ artifact_id: "artifact-1".to_string(),
+ schema_id: "radroots.private.fulfillment.v1".to_string(),
+ ciphertext_commitment: hex_64('f'),
+ required_acknowledgement: true,
+ });
+ }
+ let root = recanonicalize(root);
+ let candidate_id = match &root.body {
+ TradeMutationBodyV1::Proposal { candidate } => candidate.candidate_id.unwrap(),
+ _ => unreachable!(),
+ };
+ let decision = accepted_decision(&root, '1');
+ let evidence = |state| RadrootsTradePrivateTermsEvidenceV1::new(candidate_id, state);
+ let reduce_with = |private_terms| {
+ RadrootsTradeReductionInputV1::new(trade_id())
+ .with_mutations(vec![record(root.clone()), record(decision.clone())])
+ .with_private_terms(private_terms)
+ };
+
+ let first = reduce_trade_records(reduce_with(vec![
+ evidence(RadrootsTradePrivateTermsStateV1::AvailableVerified),
+ evidence(RadrootsTradePrivateTermsStateV1::CommitmentMismatch),
+ ]));
+ let second = reduce_trade_records(reduce_with(vec![
+ evidence(RadrootsTradePrivateTermsStateV1::CommitmentMismatch),
+ evidence(RadrootsTradePrivateTermsStateV1::AvailableVerified),
+ ]));
+
+ assert_eq!(
+ first.private_terms_state,
+ RadrootsTradePrivateTermsStateV1::CommitmentMismatch
+ );
+ assert_eq!(first, second);
+ }
+
+ #[test]
+ fn reducer_attestation_order_and_duplicates_do_not_change_digest() {
+ let root = proposal();
+ let decision = accepted_decision(&root, '1');
+ let valid = RadrootsTradeAttestationRecordV1::new(
+ event_id('8'),
+ root_id(&decision),
+ RadrootsTradeAttestationResultV1::Valid,
+ );
+ let invalid = RadrootsTradeAttestationRecordV1::new(
+ event_id('9'),
+ root_id(&decision),
+ RadrootsTradeAttestationResultV1::Invalid,
+ );
+ let reduce_with = |attestations| {
+ reduce_trade_records(
+ RadrootsTradeReductionInputV1::new(trade_id())
+ .with_mutations(vec![record(root.clone()), record(decision.clone())])
+ .with_attestations(attestations),
+ )
+ };
+
+ let first = reduce_with(vec![valid.clone(), invalid.clone(), valid.clone()]);
+ let second = reduce_with(vec![invalid, valid]);
+
+ assert_eq!(
+ first.attestation_state,
+ RadrootsTradeAttestationStateV1::Conflicting
+ );
+ assert_eq!(first.attestations.len(), 2);
+ assert_eq!(first, second);
+ }
+
+ #[test]
+ fn reducer_conformance_inventory_covers_deterministic_edge_cases() {
+ let document: serde_json::Value =
+ serde_json::from_str(REDUCER_VECTORS).expect("reducer vectors parse");
+ let ids = document["vectors"]
+ .as_array()
+ .expect("reducer vector array")
+ .iter()
+ .map(|vector| vector["id"].as_str().expect("reducer vector id"))
+ .collect::<BTreeSet<_>>();
+
+ for required in [
+ "trade_reduce_agreed_projection_digest_001",
+ "trade_reduce_contested_claims_002",
+ "trade_reduce_attestation_only_003",
+ "trade_reduce_missing_parent_004",
+ "trade_reduce_unsupported_version_isolated_005",
+ "trade_reduce_private_evidence_precedence_006",
+ "trade_reduce_attestation_deduplication_007",
+ ] {
+ assert!(ids.contains(required), "missing reducer vector {required}");
+ }
+ }
+
+ #[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.agreement_state,
+ RadrootsTradeAgreementStateV1::Contested
+ );
+ 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 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 reducer_keeps_missing_parents_as_incomplete_evidence() {
+ let proposal = proposal();
+ let mut decision = accepted_decision(&proposal, '1');
+ let missing_parent = MutationId::parse(hex_64('e')).unwrap();
+ decision.parent_mutation_ids = vec![missing_parent];
+ let decision = canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
+ mutation_id: None,
+ ..decision
+ })
+ .unwrap()
+ .envelope;
+ let projection = reduce(vec![proposal, decision]);
+
+ 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 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 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 reducer_tracks_private_terms_without_hiding_claims() {
+ let mut root = proposal();
+ if let TradeMutationBodyV1::Proposal { candidate } = &mut root.body {
+ candidate.private_terms = Some(TradePrivateTermsRefV1 {
+ 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(TradeMutationEnvelopeV1 {
+ mutation_id: None,
+ ..root
+ })
+ .unwrap()
+ .envelope;
+ let decision = accepted_decision(&root, '1');
+ let projection = reduce(vec![root, decision]);
+
+ assert_eq!(
+ projection.agreement_state,
+ RadrootsTradeAgreementStateV1::Agreed
+ );
+ assert_eq!(
+ projection.private_terms_state,
+ RadrootsTradePrivateTermsStateV1::Missing
+ );
+ assert_eq!(projection.active_agreement_claim_ids.len(), 1);
+ }
+
+ #[test]
+ 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
+ );
+ assert_eq!(
+ declined_projection.agreement_state,
+ RadrootsTradeAgreementStateV1::None
+ );
+
+ 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
+ );
+ assert_eq!(
+ expired_projection.agreement_state,
+ RadrootsTradeAgreementStateV1::None
+ );
+ }
+
+ #[test]
+ fn reducer_classifies_malformed_unsupported_and_foreign_records() {
+ let empty = reduce(Vec::new());
+ assert_eq!(
+ empty.negotiation_state,
+ RadrootsTradeNegotiationStateV1::None
+ );
+ assert_eq!(empty.evidence_state, RadrootsTradeEvidenceStateV1::Missing);
+ assert!(
+ empty
+ .issues
+ .contains(&RadrootsTradeReducerIssueV1::MissingRootProposal)
+ );
+
+ let mut missing_id = proposal();
+ missing_id.mutation_id = None;
+ let mut unsupported = proposal();
+ unsupported.schema_version += 1;
+ let unsupported_id = root_id(&unsupported);
+ let mut invalid = proposal();
+ invalid.contract_id = "invalid.contract".to_string();
+ let mut second_root = proposal();
+ second_root.authored_at_unix_s += 1;
+ let second_root = recanonicalize(second_root);
+ let projection = reduce(vec![
+ missing_id.clone(),
+ missing_id,
+ unsupported,
+ invalid,
+ proposal(),
+ second_root,
+ ]);
+ assert!(
+ projection
+ .issues
+ .contains(&RadrootsTradeReducerIssueV1::MissingMutationId)
+ );
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::UnsupportedSchema { mutation_id, .. }
+ if mutation_id == &unsupported_id
+ )));
+ assert!(
+ projection
+ .issues
+ .iter()
+ .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::InvalidMutation { .. }))
+ );
+ assert!(
+ projection
+ .issues
+ .contains(&RadrootsTradeReducerIssueV1::MultipleRootProposals)
+ );
+ assert_eq!(
+ projection.conflict_state,
+ RadrootsTradeConflictStateV1::ConcurrentCandidates
+ );
+
+ let foreign_trade = TradeId::parse(hex_32('2')).expect("foreign trade");
+ let mut input = RadrootsTradeReductionInputV1::new(foreign_trade);
+ input.mutations = vec![record(proposal())];
+ let foreign = reduce_trade_records(input);
+ assert!(foreign.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::TradeIdentityMismatch { .. }
+ )));
+ }
+
+ #[test]
+ fn reducer_rejects_invalid_decision_relationships() {
+ let root = proposal();
+
+ let missing_proposal = reduce(vec![accepted_decision(&root, '1')]);
+ assert!(
+ missing_proposal
+ .issues
+ .iter()
+ .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::MissingProposal { .. }))
+ );
+
+ let mut wrong_candidate = accepted_decision(&root, '1');
+ if let TradeMutationBodyV1::Decision { candidate_id, .. } = &mut wrong_candidate.body {
+ *candidate_id = CandidateId::parse(hex_64('f')).expect("candidate id");
+ }
+ let wrong_candidate = recanonicalize(wrong_candidate);
+ let projection = reduce(vec![root.clone(), wrong_candidate]);
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::CandidateIdMismatch { .. }
+ )));
+
+ let mut wrong_author = accepted_decision(&root, '2');
+ wrong_author.author_pubkey = wrong_author.buyer_pubkey;
+ wrong_author.counterparty_pubkey = wrong_author.seller_pubkey;
+ let wrong_author = recanonicalize(wrong_author);
+ let projection = reduce(vec![root.clone(), wrong_author]);
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { .. }
+ )));
+
+ let mut no_reservation = accepted_decision(&root, '3');
+ if let TradeMutationBodyV1::Decision { decision, .. } = &mut no_reservation.body {
+ *decision = TradeDecisionV1::Accepted {
+ reservation_assertion: None,
+ };
+ }
+ let no_reservation = recanonicalize(no_reservation);
+ let projection = reduce(vec![root, no_reservation]);
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::MissingSellerReservation { .. }
+ )));
+ }
+
+ #[test]
+ fn reducer_rejects_every_reservation_mismatch() {
+ let root = proposal();
+ let mut mismatched = accepted_decision(&root, '4');
+ if let TradeMutationBodyV1::Decision {
+ decision:
+ TradeDecisionV1::Accepted {
+ reservation_assertion: Some(reservation),
+ },
+ ..
+ } = &mut mismatched.body
+ {
+ reservation.candidate_id = CandidateId::parse(hex_64('f')).expect("candidate id");
+ reservation.inventory_authority_id = pubkey('a');
+ reservation.commitments[0].unit_code = "kg".to_string();
+ }
+ let mismatched = recanonicalize(mismatched);
+ let projection = reduce(vec![root.clone(), mismatched]);
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::ReservationCandidateMismatch { .. }
+ )));
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::ReservationAuthorityMismatch { .. }
+ )));
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. }
+ )));
+ assert_eq!(
+ projection.conflict_state,
+ RadrootsTradeConflictStateV1::InventoryAuthorityConflict
+ );
+
+ let mut wrong_count = accepted_decision(&root, '5');
+ if let TradeMutationBodyV1::Decision {
+ decision:
+ TradeDecisionV1::Accepted {
+ reservation_assertion: Some(reservation),
+ },
+ ..
+ } = &mut wrong_count.body
+ {
+ let mut extra = reservation.commitments[0].clone();
+ extra.line_id = dtag("line-2");
+ reservation.commitments.push(extra);
+ }
+ let wrong_count = recanonicalize(wrong_count);
+ let projection = reduce(vec![root, wrong_count]);
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. }
+ )));
+ }
+
+ #[test]
+ fn reducer_covers_decision_conflict_and_ordered_cancellation() {
+ let root = proposal();
+ let accepted = accepted_decision(&root, '1');
+ let declined = declined_decision(&root);
+ let conflicted = reduce(vec![root.clone(), accepted.clone(), declined]);
+ assert_eq!(
+ conflicted.conflict_state,
+ RadrootsTradeConflictStateV1::DecisionConflict
+ );
+ assert!(
+ conflicted
+ .issues
+ .iter()
+ .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::DecisionConflict { .. }))
+ );
+
+ let cancel = cancellation(&root, root_id(&accepted), root_id(&accepted));
+ let cancelled = reduce(vec![root, accepted.clone(), cancel]);
+ assert_eq!(
+ cancelled.agreement_state,
+ RadrootsTradeAgreementStateV1::Cancelled
+ );
+ assert_eq!(cancelled.cancelled_claim_ids, vec![root_id(&accepted)]);
+ }
+
+ #[test]
+ fn reducer_covers_pre_agreement_cancellation_and_requested_evidence() {
+ let root = proposal();
+ let cancel = candidate_cancellation(&root);
+ let cancelled = reduce(vec![root.clone(), cancel]);
+ assert_eq!(
+ cancelled.agreement_state,
+ RadrootsTradeAgreementStateV1::Cancelled
+ );
+
+ let decision = accepted_decision(&root, '1');
+ let mut input = RadrootsTradeReductionInputV1::new(trade_id());
+ input.mutations = vec![record(root), record(decision)];
+ input.evidence_state = RadrootsTradeEvidenceStateV1::QueryPartial;
+ assert_eq!(
+ reduce_trade_records(input).evidence_state,
+ RadrootsTradeEvidenceStateV1::QueryPartial
+ );
+ }
+
+ #[test]
+ fn reducer_covers_private_terms_and_attestation_precedence() {
+ let mut root = proposal();
+ if let TradeMutationBodyV1::Proposal { candidate } = &mut root.body {
+ candidate.private_terms = Some(TradePrivateTermsRefV1 {
+ artifact_id: "artifact-1".to_string(),
+ schema_id: "radroots.private.fulfillment.v1".to_string(),
+ ciphertext_commitment: hex_64('f'),
+ required_acknowledgement: true,
+ });
+ }
+ let root = recanonicalize(root);
+ let candidate_id = match &root.body {
+ TradeMutationBodyV1::Proposal { candidate } => {
+ candidate.candidate_id.expect("candidate id")
+ }
+ _ => unreachable!(),
+ };
+ let decision = accepted_decision(&root, '1');
+ for (state, expected) in [
+ (
+ RadrootsTradePrivateTermsStateV1::AvailableVerified,
+ RadrootsTradePrivateTermsStateV1::AvailableVerified,
+ ),
+ (
+ RadrootsTradePrivateTermsStateV1::Undecryptable,
+ RadrootsTradePrivateTermsStateV1::Undecryptable,
+ ),
+ (
+ RadrootsTradePrivateTermsStateV1::CommitmentMismatch,
+ RadrootsTradePrivateTermsStateV1::CommitmentMismatch,
+ ),
+ ] {
+ let mut input = RadrootsTradeReductionInputV1::new(trade_id());
+ input.mutations = vec![record(root.clone()), record(decision.clone())];
+ input.private_terms = vec![RadrootsTradePrivateTermsEvidenceV1 {
+ candidate_id,
+ state,
+ }];
+ assert_eq!(reduce_trade_records(input).private_terms_state, expected);
+ }
+
+ for (results, expected) in [
+ (
+ vec![RadrootsTradeAttestationResultV1::Valid],
+ RadrootsTradeAttestationStateV1::PresentValid,
+ ),
+ (
+ vec![
+ RadrootsTradeAttestationResultV1::Valid,
+ RadrootsTradeAttestationResultV1::Invalid,
+ ],
+ RadrootsTradeAttestationStateV1::Conflicting,
+ ),
+ ] {
+ let mut input = RadrootsTradeReductionInputV1::new(trade_id());
+ input.mutations = vec![record(root.clone()), record(decision.clone())];
+ input.attestations = results
+ .into_iter()
+ .enumerate()
+ .map(|(index, result)| RadrootsTradeAttestationRecordV1 {
+ event_id: event_id(if index == 0 { '8' } else { '9' }),
+ claim_mutation_id: root_id(&decision),
+ result,
+ })
+ .collect();
+ assert_eq!(reduce_trade_records(input).attestation_state, expected);
+ }
+ }
+
+ #[test]
+ fn reducer_private_helpers_cover_empty_graph_and_conflict_precedence() {
+ let missing = MutationId::parse(hex_64('e')).expect("mutation id");
+ assert!(ancestors_of(&missing, &BTreeMap::new(), &mut BTreeMap::new()).is_empty());
+
+ let mut conflict = RadrootsTradeConflictStateV1::DecisionConflict;
+ set_conflict(
+ &mut conflict,
+ RadrootsTradeConflictStateV1::ConcurrentCandidates,
+ );
+ assert_eq!(conflict, RadrootsTradeConflictStateV1::DecisionConflict);
+ set_conflict(
+ &mut conflict,
+ RadrootsTradeConflictStateV1::InvalidCausalChain,
+ );
+ assert_eq!(conflict, RadrootsTradeConflictStateV1::InvalidCausalChain);
+ }
+
+ #[test]
+ fn reducer_rejects_self_identified_decision_author_bypass() {
+ let root = proposal();
+ let mut decision = accepted_decision(&root, '7');
+ decision.author_pubkey = decision.buyer_pubkey;
+ decision.counterparty_pubkey = decision.buyer_pubkey;
+ let decision = recanonicalize(decision);
+
+ let projection = reduce(vec![root, decision]);
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { .. }
+ )));
+ }
+
+ #[test]
+ fn reducer_covers_all_decline_and_counterparty_shapes() {
+ let root = proposal();
+ let first = declined_decision(&root);
+ let mut second = first.clone();
+ second.authored_at_unix_s += 1;
+ if let TradeMutationBodyV1::Decision {
+ decision: TradeDecisionV1::Declined { reason },
+ ..
+ } = &mut second.body
+ {
+ *reason = "still unavailable".to_string();
+ }
+ let second = recanonicalize(second);
+ let projection = reduce(vec![root.clone(), first, second]);
+ assert_eq!(
+ projection.negotiation_state,
+ RadrootsTradeNegotiationStateV1::ClosedDeclined
+ );
+
+ let revision = revision_proposal(&root, vec![root_id(&root)]);
+ let mut revision_decline = declined_decision(&revision);
+ let TradeMutationBodyV1::Decision {
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ } = revision_decline.body
+ else {
+ unreachable!();
+ };
+ revision_decline.contract_id = RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID.to_string();
+ revision_decline.root_mutation_id = Some(root_id(&root));
+ revision_decline.body = TradeMutationBodyV1::RevisionDecision {
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ };
+ let revision_decline = recanonicalize(revision_decline);
+ assert_eq!(
+ declined_candidate_ids(&BTreeMap::from([(
+ root_id(&revision_decline),
+ revision_decline,
+ )])),
+ vec![candidate_id]
+ );
+
+ let candidate = match &root.body {
+ TradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
+ _ => unreachable!(),
+ };
+ let mut candidate_record = CandidateRecord {
+ proposal_mutation_id: root_id(&root),
+ author_pubkey: root.buyer_pubkey,
+ candidate,
+ };
+ let decision = accepted_decision(&root, '8');
+ assert_eq!(
+ candidate_record_author_counterparty(&candidate_record, &decision),
+ decision.seller_pubkey
+ );
+ candidate_record.author_pubkey = decision.seller_pubkey;
+ assert_eq!(
+ candidate_record_author_counterparty(&candidate_record, &decision),
+ decision.buyer_pubkey
+ );
+ }
+
+ #[test]
+ fn reservation_line_validation_checks_each_field() {
+ let root = proposal();
+ let candidate = match &root.body {
+ TradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
+ _ => unreachable!(),
+ };
+ let candidate_id = candidate.candidate_id.expect("candidate id");
+ let decision_mutation_id = root_id(&accepted_decision(&root, '9'));
+
+ for field in 0..5 {
+ let mut reservation = reservation(&candidate, '9');
+ match field {
+ 0 => reservation.commitments[0].line_id = dtag("line-other"),
+ 1 => reservation.commitments[0].bin_id = bin_id("bin-other"),
+ 2 => reservation.commitments[0].quantity_mantissa = "3".to_string(),
+ 3 => reservation.commitments[0].quantity_scale = 1,
+ 4 => reservation.commitments[0].unit_code = "kg".to_string(),
+ _ => unreachable!(),
+ }
+ let mut projection = RadrootsTradeProjectionV1::empty(trade_id());
+ assert!(!validate_reservation(
+ &decision_mutation_id,
+ &candidate_id,
+ &candidate,
+ &reservation,
+ &mut projection,
+ ));
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. }
+ )));
+ }
+ }
+
+ #[test]
+ fn agreement_and_cancellation_helpers_cover_nonterminal_shapes() {
+ let root = proposal();
+ let first_decision = accepted_decision(&root, '1');
+ let second_decision = accepted_decision(&root, '2');
+ let first_id = root_id(&first_decision);
+ let second_id = root_id(&second_decision);
+ let candidate = match &root.body {
+ TradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
+ _ => unreachable!(),
+ };
+ let candidate_id = candidate.candidate_id.expect("candidate id");
+ let claim = |claim_mutation_id: MutationId| RadrootsTradeAgreementClaimV1 {
+ claim_mutation_id,
+ proposal_mutation_id: root_id(&root),
+ candidate_id,
+ candidate_author_pubkey: root.buyer_pubkey,
+ accepted_by_pubkey: root.seller_pubkey,
+ reservation_commitment: hex_64('a'),
+ };
+ let claims = BTreeMap::from([(first_id, claim(first_id)), (second_id, claim(second_id))]);
+ let mutations = BTreeMap::from([(first_id, first_decision), (second_id, second_decision)]);
+ let missing_claim = MutationId::parse(hex_64('e')).expect("missing claim");
+ let cancellations = vec![
+ CancellationRecord {
+ mutation_id: MutationId::parse(hex_64('3')).expect("cancellation"),
+ parent_mutation_ids: Vec::new(),
+ target_candidate_id: None,
+ target_claim_mutation_id: None,
+ },
+ CancellationRecord {
+ mutation_id: MutationId::parse(hex_64('4')).expect("cancellation"),
+ parent_mutation_ids: Vec::new(),
+ target_candidate_id: None,
+ target_claim_mutation_id: Some(missing_claim),
+ },
+ CancellationRecord {
+ mutation_id: MutationId::parse(hex_64('5')).expect("cancellation"),
+ parent_mutation_ids: vec![first_id],
+ target_candidate_id: None,
+ target_claim_mutation_id: Some(first_id),
+ },
+ ];
+ let mut projection = RadrootsTradeProjectionV1::empty(trade_id());
+ apply_agreement_state(
+ &mut projection,
+ &claims,
+ &mutations,
+ &BTreeMap::new(),
+ &cancellations,
+ );
+ assert_eq!(
+ projection.agreement_state,
+ RadrootsTradeAgreementStateV1::Agreed
+ );
+ assert_eq!(projection.cancelled_claim_ids, vec![first_id]);
+
+ let mut incompatible_claims = claims.clone();
+ incompatible_claims
+ .get_mut(&second_id)
+ .expect("second claim")
+ .candidate_id = CandidateId::parse(hex_64('f')).expect("candidate");
+ let mut incompatible = RadrootsTradeProjectionV1::empty(trade_id());
+ apply_agreement_state(
+ &mut incompatible,
+ &incompatible_claims,
+ &mutations,
+ &BTreeMap::new(),
+ &[],
+ );
+ assert_eq!(
+ incompatible.conflict_state,
+ RadrootsTradeConflictStateV1::DecisionConflict
+ );
+
+ let candidates = BTreeMap::from([(
+ root_id(&root),
+ CandidateRecord {
+ proposal_mutation_id: root_id(&root),
+ author_pubkey: root.author_pubkey,
+ candidate: candidate.clone(),
+ },
+ )]);
+ let unknown_candidate = CandidateId::parse(hex_64('f')).expect("candidate");
+ assert!(!cancellation_without_claim(
+ &[CancellationRecord {
+ mutation_id: MutationId::parse(hex_64('6')).expect("cancellation"),
+ parent_mutation_ids: Vec::new(),
+ target_candidate_id: Some(unknown_candidate),
+ target_claim_mutation_id: None,
+ }],
+ &candidates,
+ ));
+ let mut disabled_candidate = candidate;
+ disabled_candidate.cancellation.buyer_pre_agreement = false;
+ let disabled_candidates = BTreeMap::from([(
+ root_id(&root),
+ CandidateRecord {
+ proposal_mutation_id: root_id(&root),
+ author_pubkey: root.author_pubkey,
+ candidate: disabled_candidate,
+ },
+ )]);
+ assert!(!cancellation_without_claim(
+ &[CancellationRecord {
+ mutation_id: MutationId::parse(hex_64('7')).expect("cancellation"),
+ parent_mutation_ids: Vec::new(),
+ target_candidate_id: Some(candidate_id),
+ target_claim_mutation_id: None,
+ }],
+ &disabled_candidates,
+ ));
+ }
+
+ #[test]
+ fn evidence_state_covers_missing_proposal_independently() {
+ let root = proposal();
+ let mut projection = RadrootsTradeProjectionV1::empty(trade_id());
+ projection.root_mutation_id = Some(root_id(&root));
+ projection
+ .missing_proposal_ids
+ .push(MutationId::parse(hex_64('e')).expect("proposal"));
+ assert_eq!(
+ reduce_evidence_state(&projection, RadrootsTradeEvidenceStateV1::Complete),
+ RadrootsTradeEvidenceStateV1::Missing
+ );
+ }
+}
diff --git a/crates/trade/src/workflow.rs b/crates/trade/src/workflow.rs
@@ -1,2633 +1,512 @@
-#![forbid(unsafe_code)]
+//! Validated, side-effect-free trade workflow plans.
-#[cfg(all(not(feature = "std"), feature = "serde_json"))]
-use alloc::format;
#[cfg(not(feature = "std"))]
-use alloc::{
- collections::{BTreeMap, BTreeSet},
- string::{String, ToString},
- vec::Vec,
-};
+use alloc::{format, string::String, vec, vec::Vec};
#[cfg(feature = "std")]
-use std::{
- collections::{BTreeMap, BTreeSet},
- string::{String, ToString},
- vec::Vec,
-};
+use std::{string::String, vec, vec::Vec};
+
+use core::fmt;
use radroots_event::{
- id::{CandidateId, DTag, EventId, MutationId, TradeId},
+ id::{CandidateId, MutationId, TradeId},
trade::{
- RADROOTS_TRADE_SCHEMA_VERSION, SellerReservationAssertionV1, TradeCandidateTermsV1,
- TradeDecisionV1, TradeMutationBodyV1, TradeMutationEnvelopeV1,
+ RADROOTS_TRADE_SCHEMA_VERSION, TradeMutationBodyV1, TradeMutationEnvelopeV1,
+ TradeMutationKindV1,
},
};
-use radroots_identity::PublicKey;
-#[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")]
-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, 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,
-}
-
-#[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_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))]
+/// A host action required to execute a prepared trade plan.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
+#[non_exhaustive]
+pub enum WorkflowAction {
+ /// Authorize and sign the canonical mutation.
+ Sign,
+ /// Persist the signed mutation atomically.
+ Persist,
+ /// Deliver the signed event through an explicitly selected transport.
+ Deliver,
+ /// Verify required private terms before signing.
+ VerifyPrivateTerms,
+}
+
+/// Private-term material a host must verify before executing a plan.
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsTradeReductionInputV1 {
- trade_id: TradeId,
- mutations: Vec<RadrootsTradeMutationRecordV1>,
- private_terms: Vec<RadrootsTradePrivateTermsEvidenceV1>,
- attestations: Vec<RadrootsTradeAttestationRecordV1>,
- evidence_state: RadrootsTradeEvidenceStateV1,
- observed_at_unix_s: Option<u64>,
+pub struct PrivateTermsPlan {
+ candidate_id: CandidateId,
+ artifact_id: String,
+ schema_id: String,
+ ciphertext_commitment: String,
}
-impl RadrootsTradeReductionInputV1 {
- pub fn new(trade_id: TradeId) -> Self {
- Self {
- trade_id,
- mutations: Vec::new(),
- private_terms: Vec::new(),
- attestations: Vec::new(),
- evidence_state: RadrootsTradeEvidenceStateV1::Complete,
- observed_at_unix_s: None,
- }
- }
-
- #[must_use]
- pub fn with_mutations(mut self, mutations: Vec<RadrootsTradeMutationRecordV1>) -> Self {
- self.mutations = mutations;
- self
- }
-
- #[must_use]
- pub fn with_private_terms(
- mut self,
- private_terms: Vec<RadrootsTradePrivateTermsEvidenceV1>,
- ) -> Self {
- self.private_terms = private_terms;
- self
- }
-
- #[must_use]
- pub fn with_attestations(
- mut self,
- attestations: Vec<RadrootsTradeAttestationRecordV1>,
- ) -> Self {
- self.attestations = attestations;
- self
- }
-
- #[must_use]
- pub fn with_evidence_state(mut self, evidence_state: RadrootsTradeEvidenceStateV1) -> Self {
- self.evidence_state = evidence_state;
- self
- }
-
- #[must_use]
- pub fn with_observed_at_unix_s(mut self, observed_at_unix_s: Option<u64>) -> Self {
- self.observed_at_unix_s = observed_at_unix_s;
- self
- }
-
- pub const fn trade_id(&self) -> &TradeId {
- &self.trade_id
- }
-
- pub fn mutations(&self) -> &[RadrootsTradeMutationRecordV1] {
- &self.mutations
- }
-
- pub fn private_terms(&self) -> &[RadrootsTradePrivateTermsEvidenceV1] {
- &self.private_terms
+impl PrivateTermsPlan {
+ /// Returns the candidate whose private terms must be verified.
+ pub const fn candidate_id(&self) -> &CandidateId {
+ &self.candidate_id
}
- pub fn attestations(&self) -> &[RadrootsTradeAttestationRecordV1] {
- &self.attestations
+ /// Returns the host-owned encrypted artifact identifier.
+ pub fn artifact_id(&self) -> &str {
+ &self.artifact_id
}
- pub const fn evidence_state(&self) -> RadrootsTradeEvidenceStateV1 {
- self.evidence_state
+ /// Returns the private-term schema identifier.
+ pub fn schema_id(&self) -> &str {
+ &self.schema_id
}
- pub const fn observed_at_unix_s(&self) -> Option<u64> {
- self.observed_at_unix_s
+ /// Returns the expected ciphertext commitment.
+ pub fn ciphertext_commitment(&self) -> &str {
+ &self.ciphertext_commitment
}
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+/// A validated description of a trade operation and its required host actions.
+///
+/// Constructing a plan performs no signing, persistence, delivery, filesystem
+/// access, scheduling, or private-artifact access.
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsTradeMutationRecordV1 {
- transport_event_id: Option<EventId>,
+#[must_use = "a workflow plan has no effect until a host explicitly executes its actions"]
+pub struct WorkflowPlan {
mutation: TradeMutationEnvelopeV1,
-}
-
-impl RadrootsTradeMutationRecordV1 {
- pub const fn new(
- transport_event_id: Option<EventId>,
- mutation: TradeMutationEnvelopeV1,
- ) -> Self {
- Self {
- transport_event_id,
- mutation,
+ mutation_id: MutationId,
+ kind: TradeMutationKindV1,
+ required_actions: Vec<WorkflowAction>,
+ private_terms: Option<PrivateTermsPlan>,
+}
+
+impl WorkflowPlan {
+ /// Validates a canonical trade mutation and prepares its host action plan.
+ pub fn prepare(mutation: TradeMutationEnvelopeV1) -> Result<Self, Error> {
+ if mutation.schema_version != RADROOTS_TRADE_SCHEMA_VERSION {
+ return Err(Error::unsupported_schema(mutation.schema_version));
+ }
+ let mutation_id = mutation
+ .mutation_id
+ .ok_or_else(Error::missing_mutation_id)?;
+ mutation.validate().map_err(Error::invalid_mutation)?;
+
+ let kind = mutation.mutation_kind();
+ let private_terms = private_terms_plan(&mutation.body)?;
+ let mut required_actions = vec![
+ WorkflowAction::Sign,
+ WorkflowAction::Persist,
+ WorkflowAction::Deliver,
+ ];
+ if private_terms.is_some() {
+ required_actions.insert(0, WorkflowAction::VerifyPrivateTerms);
}
- }
- pub const fn transport_event_id(&self) -> Option<&EventId> {
- self.transport_event_id.as_ref()
+ debug_assert_eq!(mutation.mutation_id, Some(mutation_id));
+ Ok(Self {
+ mutation,
+ mutation_id,
+ kind,
+ required_actions,
+ private_terms,
+ })
}
- pub const fn mutation(&self) -> &TradeMutationEnvelopeV1 {
- &self.mutation
+ /// Returns the operation kind represented by the plan.
+ pub const fn kind(&self) -> TradeMutationKindV1 {
+ self.kind
}
-}
-
-#[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 {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- candidate_id: CandidateId,
- state: RadrootsTradePrivateTermsStateV1,
-}
-impl RadrootsTradePrivateTermsEvidenceV1 {
- pub const fn new(candidate_id: CandidateId, state: RadrootsTradePrivateTermsStateV1) -> Self {
- Self {
- candidate_id,
- state,
- }
+ /// Returns the canonical mutation identifier.
+ pub const fn mutation_id(&self) -> &MutationId {
+ &self.mutation_id
}
- pub const fn candidate_id(&self) -> &CandidateId {
- &self.candidate_id
+ /// Returns the canonical trade identifier.
+ pub const fn trade_id(&self) -> &TradeId {
+ &self.mutation.trade_id
}
- pub const fn state(&self) -> RadrootsTradePrivateTermsStateV1 {
- self.state
+ /// Returns the validated canonical mutation without transferring ownership.
+ pub const fn mutation(&self) -> &TradeMutationEnvelopeV1 {
+ &self.mutation
}
-}
-
-#[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, PartialOrd, Ord)]
-pub struct RadrootsTradeAttestationRecordV1 {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- event_id: EventId,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- claim_mutation_id: MutationId,
- result: RadrootsTradeAttestationResultV1,
-}
-impl RadrootsTradeAttestationRecordV1 {
- pub const fn new(
- event_id: EventId,
- claim_mutation_id: MutationId,
- result: RadrootsTradeAttestationResultV1,
- ) -> Self {
- Self {
- event_id,
- claim_mutation_id,
- result,
- }
+ /// Returns the ordered host actions required to execute the plan.
+ pub fn required_actions(&self) -> &[WorkflowAction] {
+ &self.required_actions
}
- pub const fn event_id(&self) -> &EventId {
- &self.event_id
+ /// Returns required private-term verification material, when present.
+ pub const fn private_terms(&self) -> Option<&PrivateTermsPlan> {
+ self.private_terms.as_ref()
}
- pub const fn claim_mutation_id(&self) -> &MutationId {
- &self.claim_mutation_id
+ /// Consumes the plan and returns the validated canonical mutation.
+ pub fn into_mutation(self) -> TradeMutationEnvelopeV1 {
+ self.mutation
}
+}
- pub const fn result(&self) -> RadrootsTradeAttestationResultV1 {
- self.result
- }
+/// Stable workflow-planning error classification.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[non_exhaustive]
+pub enum ErrorKind {
+ /// The mutation uses an unsupported trade schema.
+ UnsupportedSchema,
+ /// The canonical mutation identifier is absent.
+ MissingMutationId,
+ /// The mutation violates its event-domain contract.
+ InvalidMutation,
+ /// Required private-term metadata is incomplete.
+ InvalidPrivateTerms,
}
-#[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))]
+/// Workflow-plan validation failure with a redacted diagnostic.
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsTradeProjectionV1 {
- reducer_contract_id: String,
- reducer_version: u16,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- trade_id: TradeId,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- root_mutation_id: Option<MutationId>,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- buyer_pubkey: Option<PublicKey>,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- seller_pubkey: Option<PublicKey>,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- farm_id: Option<DTag>,
- negotiation_state: RadrootsTradeNegotiationStateV1,
- agreement_state: RadrootsTradeAgreementStateV1,
- evidence_state: RadrootsTradeEvidenceStateV1,
- conflict_state: RadrootsTradeConflictStateV1,
- private_terms_state: RadrootsTradePrivateTermsStateV1,
- attestation_state: RadrootsTradeAttestationStateV1,
- fulfillment_state: RadrootsTradeFulfillmentStateV1,
- payment_state: RadrootsTradePaymentStateV1,
- #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
- candidate_heads: Vec<MutationId>,
- agreement_claims: Vec<RadrootsTradeAgreementClaimV1>,
- #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
- active_agreement_claim_ids: Vec<MutationId>,
- #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
- contested_claim_ids: Vec<MutationId>,
- #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
- cancelled_claim_ids: Vec<MutationId>,
- #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
- declined_candidate_ids: Vec<CandidateId>,
- #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
- missing_parent_ids: Vec<MutationId>,
- #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
- missing_proposal_ids: Vec<MutationId>,
- #[cfg_attr(feature = "dto-bindgen", dto(ts(type = "Array<string>")))]
- unsupported_mutation_ids: Vec<MutationId>,
- issues: Vec<RadrootsTradeReducerIssueV1>,
- attestations: Vec<RadrootsTradeAttestationRecordV1>,
- projection_digest: String,
+pub struct Error {
+ kind: ErrorKind,
+ message: String,
+ source: Option<radroots_event::trade::TradeProtocolError>,
}
-impl RadrootsTradeProjectionV1 {
- fn empty(trade_id: TradeId) -> Self {
+impl Error {
+ fn unsupported_schema(found: u16) -> 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(),
+ kind: ErrorKind::UnsupportedSchema,
+ message: format!(
+ "unsupported trade schema version {found}; expected {RADROOTS_TRADE_SCHEMA_VERSION}"
+ ),
+ source: None,
}
}
- 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_key(|left| left.claim_mutation_id);
- self.issues.sort();
- self.issues.dedup();
- self.attestations.sort();
- self.attestations.dedup();
- match projection_digest(self) {
- Ok(digest) => self.projection_digest = digest,
- Err(reason) => {
- self.projection_digest.clear();
- self.issues
- .push(RadrootsTradeReducerIssueV1::ProjectionDigestUnavailable { reason });
- self.issues.sort();
- self.issues.dedup();
- }
+ fn missing_mutation_id() -> Self {
+ Self {
+ kind: ErrorKind::MissingMutationId,
+ message: "canonical trade mutation identifier is missing".into(),
+ source: None,
}
}
- pub fn reducer_contract_id(&self) -> &str {
- &self.reducer_contract_id
- }
-
- pub const fn reducer_version(&self) -> u16 {
- self.reducer_version
- }
-
- pub const fn trade_id(&self) -> &TradeId {
- &self.trade_id
- }
-
- pub const fn root_mutation_id(&self) -> Option<&MutationId> {
- self.root_mutation_id.as_ref()
- }
-
- pub const fn buyer_pubkey(&self) -> Option<&PublicKey> {
- self.buyer_pubkey.as_ref()
- }
-
- pub const fn seller_pubkey(&self) -> Option<&PublicKey> {
- self.seller_pubkey.as_ref()
- }
-
- pub const fn farm_id(&self) -> Option<&DTag> {
- self.farm_id.as_ref()
- }
-
- pub const fn negotiation_state(&self) -> RadrootsTradeNegotiationStateV1 {
- self.negotiation_state
- }
-
- pub const fn agreement_state(&self) -> RadrootsTradeAgreementStateV1 {
- self.agreement_state
- }
-
- pub const fn evidence_state(&self) -> RadrootsTradeEvidenceStateV1 {
- self.evidence_state
- }
-
- pub const fn conflict_state(&self) -> RadrootsTradeConflictStateV1 {
- self.conflict_state
- }
-
- pub const fn private_terms_state(&self) -> RadrootsTradePrivateTermsStateV1 {
- self.private_terms_state
- }
-
- pub const fn attestation_state(&self) -> RadrootsTradeAttestationStateV1 {
- self.attestation_state
- }
-
- pub const fn fulfillment_state(&self) -> RadrootsTradeFulfillmentStateV1 {
- self.fulfillment_state
- }
-
- pub const fn payment_state(&self) -> RadrootsTradePaymentStateV1 {
- self.payment_state
- }
-
- pub fn candidate_heads(&self) -> &[MutationId] {
- &self.candidate_heads
- }
-
- pub fn agreement_claims(&self) -> &[RadrootsTradeAgreementClaimV1] {
- &self.agreement_claims
- }
-
- pub fn active_agreement_claim_ids(&self) -> &[MutationId] {
- &self.active_agreement_claim_ids
- }
-
- pub fn contested_claim_ids(&self) -> &[MutationId] {
- &self.contested_claim_ids
- }
-
- pub fn cancelled_claim_ids(&self) -> &[MutationId] {
- &self.cancelled_claim_ids
- }
-
- pub fn declined_candidate_ids(&self) -> &[CandidateId] {
- &self.declined_candidate_ids
- }
-
- pub fn missing_parent_ids(&self) -> &[MutationId] {
- &self.missing_parent_ids
- }
-
- pub fn missing_proposal_ids(&self) -> &[MutationId] {
- &self.missing_proposal_ids
+ fn invalid_mutation(source: radroots_event::trade::TradeProtocolError) -> Self {
+ Self {
+ kind: ErrorKind::InvalidMutation,
+ message: format!("invalid canonical trade mutation: {source}"),
+ source: Some(source),
+ }
}
- pub fn unsupported_mutation_ids(&self) -> &[MutationId] {
- &self.unsupported_mutation_ids
+ fn invalid_private_terms(message: &'static str) -> Self {
+ Self {
+ kind: ErrorKind::InvalidPrivateTerms,
+ message: message.into(),
+ source: None,
+ }
}
- pub fn issues(&self) -> &[RadrootsTradeReducerIssueV1] {
- &self.issues
+ /// Returns the stable error classification.
+ pub const fn kind(&self) -> ErrorKind {
+ self.kind
}
- pub fn attestations(&self) -> &[RadrootsTradeAttestationRecordV1] {
- &self.attestations
+ /// Returns the underlying event-domain validation failure, when present.
+ pub const fn protocol_error(&self) -> Option<&radroots_event::trade::TradeProtocolError> {
+ self.source.as_ref()
}
+}
- pub fn projection_digest(&self) -> &str {
- &self.projection_digest
+impl fmt::Display for Error {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.message)
}
}
-#[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 {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- claim_mutation_id: MutationId,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- proposal_mutation_id: MutationId,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- candidate_id: CandidateId,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- candidate_author_pubkey: PublicKey,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- accepted_by_pubkey: PublicKey,
- reservation_commitment: String,
+impl core::error::Error for Error {
+ #[cfg(feature = "std")]
+ fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
+ self.source
+ .as_ref()
+ .map(|source| source as &(dyn core::error::Error + 'static))
+ }
}
-impl RadrootsTradeAgreementClaimV1 {
- pub const fn claim_mutation_id(&self) -> &MutationId {
- &self.claim_mutation_id
- }
+fn private_terms_plan(body: &TradeMutationBodyV1) -> Result<Option<PrivateTermsPlan>, Error> {
+ let candidate = match body {
+ TradeMutationBodyV1::Proposal { candidate }
+ | TradeMutationBodyV1::RevisionProposal { candidate } => candidate,
+ TradeMutationBodyV1::Decision { .. }
+ | TradeMutationBodyV1::RevisionDecision { .. }
+ | TradeMutationBodyV1::Cancellation { .. } => return Ok(None),
+ };
+ let required =
+ candidate.fulfillment.requires_private_terms || candidate.private_terms.is_some();
+ if !required {
+ return Ok(None);
+ }
+ let candidate_id = candidate.candidate_id.ok_or_else(|| {
+ Error::invalid_private_terms("required private terms need a canonical candidate identifier")
+ })?;
+ let private_terms = candidate.private_terms.as_ref().ok_or_else(|| {
+ Error::invalid_private_terms("required private terms need an encrypted artifact reference")
+ })?;
+ Ok(Some(PrivateTermsPlan {
+ candidate_id,
+ artifact_id: private_terms.artifact_id.clone(),
+ schema_id: private_terms.schema_id.clone(),
+ ciphertext_commitment: private_terms.ciphertext_commitment.clone(),
+ }))
+}
+
+// Temporary migration reexports for the existing versioned reducer contract.
+pub use crate::trade_contract_v1::{
+ RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION,
+ RadrootsTradeAgreementClaimV1, RadrootsTradeAgreementStateV1, RadrootsTradeAttestationRecordV1,
+ RadrootsTradeAttestationResultV1, RadrootsTradeAttestationStateV1,
+ RadrootsTradeConflictStateV1, RadrootsTradeEvidenceStateV1, RadrootsTradeFulfillmentStateV1,
+ RadrootsTradeMutationRecordV1, RadrootsTradeNegotiationStateV1, RadrootsTradePaymentStateV1,
+ RadrootsTradePrivateTermsEvidenceV1, RadrootsTradePrivateTermsStateV1,
+ RadrootsTradeProjectionV1, RadrootsTradeReducerIssueV1, RadrootsTradeReductionInputV1,
+ reduce_trade_records,
+};
- pub const fn proposal_mutation_id(&self) -> &MutationId {
- &self.proposal_mutation_id
- }
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use radroots_event::{
+ id::{ClassifiedListingAddress, DTag, EventId, InventoryBinId},
+ trade::{
+ FulfillmentProfileV1, RADROOTS_TRADE_CANCELLATION_CONTRACT_ID,
+ RADROOTS_TRADE_DECISION_CONTRACT_ID, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID,
+ RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID,
+ RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, TradeCancellationProfileV1,
+ TradeCandidateLineV1, TradeCandidateTermsV1, TradeDecisionV1,
+ TradeEconomicAdjustmentV1, TradeEconomicsProfileV1, TradeLineTombstoneV1,
+ TradePrivateTermsRefV1, canonical_trade_mutation_content,
+ },
+ };
+ use radroots_identity::PublicKey;
+ use radroots_test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_BOB_PUBLIC_KEY_HEX};
- pub const fn candidate_id(&self) -> &CandidateId {
- &self.candidate_id
+ fn pubkey(value: &str) -> PublicKey {
+ PublicKey::from_hex(value).expect("fixture public key")
}
- pub const fn candidate_author_pubkey(&self) -> &PublicKey {
- &self.candidate_author_pubkey
+ fn mutation_id(marker: char) -> MutationId {
+ MutationId::parse(core::iter::repeat_n(marker, 64).collect::<String>())
+ .expect("fixture mutation id")
}
- pub const fn accepted_by_pubkey(&self) -> &PublicKey {
- &self.accepted_by_pubkey
+ fn candidate(suffix: &str) -> TradeCandidateTermsV1 {
+ TradeCandidateTermsV1 {
+ candidate_id: None,
+ schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
+ base_candidate_id: None,
+ supersession_intent: None,
+ buyer_pubkey: pubkey(FIXTURE_ALICE_PUBLIC_KEY_HEX),
+ seller_pubkey: pubkey(FIXTURE_BOB_PUBLIC_KEY_HEX),
+ farm_id: DTag::parse("farm-1").expect("farm id"),
+ lines: vec![TradeCandidateLineV1 {
+ line_id: DTag::parse(format!("line-{suffix}")).expect("line id"),
+ listing_addr: ClassifiedListingAddress::parse(format!(
+ "30402:{FIXTURE_BOB_PUBLIC_KEY_HEX}:listing-{suffix}"
+ ))
+ .expect("listing address"),
+ listing_event_id: EventId::parse("cc".repeat(32)).expect("event id"),
+ listing_snapshot_sha256: "dd".repeat(32),
+ product_id: format!("carrots-{suffix}"),
+ option_id: None,
+ bin_id: InventoryBinId::parse(format!("bin-{suffix}")).expect("bin id"),
+ quantity_mantissa: "2".into(),
+ quantity_scale: 0,
+ unit_code: "count".into(),
+ unit_profile: "mvp-count".into(),
+ unit_price_mantissa: "500".into(),
+ currency_code: "USD".into(),
+ line_subtotal_mantissa: "1000".into(),
+ replaces_line_id: None,
+ }],
+ line_tombstones: Vec::<TradeLineTombstoneV1>::new(),
+ economics: TradeEconomicsProfileV1 {
+ profile_id: "mvp-fixed".into(),
+ currency_code: "USD".into(),
+ currency_exponent: 2,
+ rounding_profile: "half-even".into(),
+ subtotal_mantissa: "1000".into(),
+ discount_total_mantissa: "0".into(),
+ adjustment_total_mantissa: "0".into(),
+ total_mantissa: "1000".into(),
+ adjustments: Vec::<TradeEconomicAdjustmentV1>::new(),
+ },
+ fulfillment: FulfillmentProfileV1 {
+ profile_id: "market-pickup".into(),
+ method: "pickup".into(),
+ starts_at_unix_s: 1_800_000_000,
+ ends_at_unix_s: 1_800_003_600,
+ timezone: "America/New_York".into(),
+ utc_offset_seconds: -18_000,
+ fold: 0,
+ location_class: "farmstand".into(),
+ requires_private_terms: true,
+ },
+ cancellation: TradeCancellationProfileV1 {
+ profile_id: "buyer-pre-agreement".into(),
+ buyer_pre_agreement: true,
+ post_agreement_cutoff_unix_s: None,
+ },
+ private_terms: Some(TradePrivateTermsRefV1 {
+ artifact_id: "artifact-1".into(),
+ schema_id: "radroots.private.fulfillment.v1".into(),
+ ciphertext_commitment: "ee".repeat(32),
+ required_acknowledgement: true,
+ }),
+ proposal_expires_at_unix_s: 1_799_999_000,
+ }
}
- pub fn reservation_commitment(&self) -> &str {
- &self.reservation_commitment
+ fn envelope(contract_id: &str, body: TradeMutationBodyV1) -> TradeMutationEnvelopeV1 {
+ let is_initial = body.mutation_kind() == TradeMutationKindV1::Proposal;
+ canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
+ mutation_id: None,
+ contract_id: contract_id.into(),
+ schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
+ trade_id: TradeId::parse("11".repeat(16)).expect("trade id"),
+ root_mutation_id: (!is_initial).then(|| mutation_id('1')),
+ buyer_pubkey: pubkey(FIXTURE_ALICE_PUBLIC_KEY_HEX),
+ seller_pubkey: pubkey(FIXTURE_BOB_PUBLIC_KEY_HEX),
+ farm_id: DTag::parse("farm-1").expect("farm id"),
+ parent_mutation_ids: if is_initial {
+ Vec::new()
+ } else {
+ vec![mutation_id('1')]
+ },
+ author_pubkey: pubkey(FIXTURE_ALICE_PUBLIC_KEY_HEX),
+ counterparty_pubkey: pubkey(FIXTURE_BOB_PUBLIC_KEY_HEX),
+ authored_at_unix_s: 100,
+ body,
+ })
+ .expect("canonical workflow mutation")
+ .envelope
}
-}
-
-#[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 {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- mutation_id: MutationId,
- },
- UnsupportedSchema {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- mutation_id: MutationId,
- schema_version: u16,
- },
- InvalidMutation {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- mutation_id: Option<MutationId>,
- reason: String,
- },
- MissingParent {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- mutation_id: MutationId,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- parent_mutation_id: MutationId,
- },
- MissingProposal {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- decision_mutation_id: MutationId,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- proposal_mutation_id: MutationId,
- },
- CandidateIdMismatch {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- decision_mutation_id: MutationId,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- proposal_mutation_id: MutationId,
- },
- DecisionAuthorMismatch {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- decision_mutation_id: MutationId,
- },
- DecisionParentMissing {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- decision_mutation_id: MutationId,
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- proposal_mutation_id: MutationId,
- },
- MissingSellerReservation {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- decision_mutation_id: MutationId,
- },
- ReservationCandidateMismatch {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- decision_mutation_id: MutationId,
- },
- ReservationAuthorityMismatch {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- decision_mutation_id: MutationId,
- },
- ReservationLineMismatch {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- decision_mutation_id: MutationId,
- },
- DecisionConflict {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- proposal_mutation_id: MutationId,
- },
- DoubleAcceptance {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- proposal_mutation_id: MutationId,
- },
- CancellationConflict {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- cancellation_mutation_id: MutationId,
- },
- InvalidCausalChain {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- mutation_id: MutationId,
- },
- PrivateTermsUnavailable {
- #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
- candidate_id: CandidateId,
- },
- ProjectionDigestUnavailable {
- reason: String,
- },
-}
-#[derive(Clone, Debug, PartialEq, Eq)]
-struct CandidateRecord {
- proposal_mutation_id: MutationId,
- author_pubkey: PublicKey,
- candidate: TradeCandidateTermsV1,
-}
-
-struct DecisionApplication<'a> {
- mutation_id: &'a MutationId,
- mutation: &'a TradeMutationEnvelopeV1,
- proposal_mutation_id: &'a MutationId,
- candidate_id: &'a CandidateId,
- decision: &'a TradeDecisionV1,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-struct CancellationRecord {
- mutation_id: MutationId,
- parent_mutation_ids: Vec<MutationId>,
- target_candidate_id: Option<CandidateId>,
- target_claim_mutation_id: Option<MutationId>,
-}
-
-pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTradeProjectionV1 {
- let mut projection = RadrootsTradeProjectionV1::empty(input.trade_id);
- let mut mutations = BTreeMap::<MutationId, TradeMutationEnvelopeV1>::new();
-
- for record in input.mutations {
- let mutation_id = match record.mutation.mutation_id {
- Some(mutation_id) => mutation_id,
- None => {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::MissingMutationId);
- continue;
+ fn all_operation_mutations() -> Vec<TradeMutationEnvelopeV1> {
+ let proposal = envelope(
+ RADROOTS_TRADE_PROPOSAL_CONTRACT_ID,
+ TradeMutationBodyV1::Proposal {
+ candidate: candidate("1"),
+ },
+ );
+ let proposal_id = proposal.mutation_id.expect("proposal id");
+ let candidate_id = match &proposal.body {
+ TradeMutationBodyV1::Proposal { candidate } => {
+ candidate.candidate_id.expect("candidate id")
}
+ _ => unreachable!(),
};
- if record.mutation.schema_version != RADROOTS_TRADE_SCHEMA_VERSION {
- projection.unsupported_mutation_ids.push(mutation_id);
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::UnsupportedSchema {
- mutation_id,
- schema_version: record.mutation.schema_version,
- });
- continue;
- }
- if let Err(error) = record.mutation.validate() {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::InvalidMutation {
- mutation_id: Some(mutation_id),
- reason: error.to_string(),
- });
- continue;
- }
- if record.mutation.trade_id != input.trade_id {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::TradeIdentityMismatch { mutation_id });
- continue;
- }
- mutations.entry(mutation_id).or_insert(record.mutation);
- }
-
- let mut root_proposals = Vec::<MutationId>::new();
- let mut candidates_by_proposal = BTreeMap::<MutationId, CandidateRecord>::new();
- let mut claims = BTreeMap::<MutationId, RadrootsTradeAgreementClaimV1>::new();
- let mut decisions_by_proposal = BTreeMap::<MutationId, Vec<MutationId>>::new();
- let mut cancellations = Vec::<CancellationRecord>::new();
- let mut referenced_parents = BTreeSet::<MutationId>::new();
-
- for (mutation_id, mutation) in &mutations {
- if matches!(mutation.body, TradeMutationBodyV1::Proposal { .. }) {
- root_proposals.push(*mutation_id);
- }
- for parent in &mutation.parent_mutation_ids {
- referenced_parents.insert(*parent);
- if !mutations.contains_key(parent) {
- projection.missing_parent_ids.push(*parent);
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::MissingParent {
- mutation_id: *mutation_id,
- parent_mutation_id: *parent,
- });
- }
- }
- match &mutation.body {
- TradeMutationBodyV1::Proposal { candidate }
- | TradeMutationBodyV1::RevisionProposal { candidate } => {
- candidates_by_proposal.insert(
- *mutation_id,
- CandidateRecord {
- proposal_mutation_id: *mutation_id,
- author_pubkey: mutation.author_pubkey,
- candidate: candidate.clone(),
+ vec![
+ proposal,
+ envelope(
+ RADROOTS_TRADE_DECISION_CONTRACT_ID,
+ TradeMutationBodyV1::Decision {
+ proposal_mutation_id: proposal_id,
+ candidate_id,
+ decision: TradeDecisionV1::Declined {
+ reason: "unavailable".into(),
},
- );
- }
- TradeMutationBodyV1::Decision { .. } | TradeMutationBodyV1::RevisionDecision { .. } => {
- }
- TradeMutationBodyV1::Cancellation {
- target_candidate_id,
- target_claim_mutation_id,
- reason: _,
- } => cancellations.push(CancellationRecord {
- mutation_id: *mutation_id,
- parent_mutation_ids: mutation.parent_mutation_ids.clone(),
- target_candidate_id: *target_candidate_id,
- target_claim_mutation_id: *target_claim_mutation_id,
- }),
- }
+ },
+ ),
+ envelope(
+ RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID,
+ TradeMutationBodyV1::RevisionProposal {
+ candidate: candidate("2"),
+ },
+ ),
+ envelope(
+ RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID,
+ TradeMutationBodyV1::RevisionDecision {
+ proposal_mutation_id: mutation_id('2'),
+ candidate_id,
+ decision: TradeDecisionV1::Declined {
+ reason: "still unavailable".into(),
+ },
+ },
+ ),
+ envelope(
+ RADROOTS_TRADE_CANCELLATION_CONTRACT_ID,
+ TradeMutationBodyV1::Cancellation {
+ target_candidate_id: Some(candidate_id),
+ target_claim_mutation_id: None,
+ reason: "cancelled".into(),
+ },
+ ),
+ ]
}
- for (mutation_id, mutation) in &mutations {
- match &mutation.body {
- TradeMutationBodyV1::Decision {
- proposal_mutation_id,
- candidate_id,
- decision,
- }
- | TradeMutationBodyV1::RevisionDecision {
- proposal_mutation_id,
- candidate_id,
- decision,
- } => {
- decisions_by_proposal
- .entry(*proposal_mutation_id)
- .or_default()
- .push(*mutation_id);
- apply_decision(
- DecisionApplication {
- mutation_id,
- mutation,
- proposal_mutation_id,
- candidate_id,
- decision,
- },
- &candidates_by_proposal,
- &mut claims,
- &mut projection,
- );
- }
- _ => {}
+ #[test]
+ fn plans_cover_every_trade_operation_without_host_side_effects() {
+ let plans = all_operation_mutations()
+ .into_iter()
+ .map(WorkflowPlan::prepare)
+ .collect::<Result<Vec<_>, _>>()
+ .expect("valid workflow plans");
+
+ assert_eq!(
+ plans.iter().map(WorkflowPlan::kind).collect::<Vec<_>>(),
+ vec![
+ TradeMutationKindV1::Proposal,
+ TradeMutationKindV1::Decision,
+ TradeMutationKindV1::RevisionProposal,
+ TradeMutationKindV1::RevisionDecision,
+ TradeMutationKindV1::Cancellation,
+ ]
+ );
+ assert_eq!(
+ plans[0].required_actions(),
+ [
+ WorkflowAction::VerifyPrivateTerms,
+ WorkflowAction::Sign,
+ WorkflowAction::Persist,
+ WorkflowAction::Deliver,
+ ]
+ );
+ assert_eq!(
+ plans[0].private_terms().unwrap().artifact_id(),
+ "artifact-1"
+ );
+ for plan in &plans[1..] {
+ let expected = if plan.kind() == TradeMutationKindV1::RevisionProposal {
+ &[
+ WorkflowAction::VerifyPrivateTerms,
+ WorkflowAction::Sign,
+ WorkflowAction::Persist,
+ WorkflowAction::Deliver,
+ ][..]
+ } else {
+ &[
+ WorkflowAction::Sign,
+ WorkflowAction::Persist,
+ WorkflowAction::Deliver,
+ ][..]
+ };
+ assert_eq!(plan.required_actions(), expected);
}
}
- if root_proposals.is_empty() {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::MissingRootProposal);
- } else if root_proposals.len() > 1 {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::MultipleRootProposals);
- set_conflict(
- &mut projection.conflict_state,
- RadrootsTradeConflictStateV1::ConcurrentCandidates,
+ #[test]
+ fn plan_validation_rejects_unsupported_missing_and_invalid_transitions() {
+ let mut unsupported = all_operation_mutations().remove(0);
+ unsupported.schema_version += 1;
+ assert_eq!(
+ WorkflowPlan::prepare(unsupported).unwrap_err().kind(),
+ ErrorKind::UnsupportedSchema
);
- } else {
- projection.root_mutation_id = root_proposals.first().cloned();
- let root = projection
- .root_mutation_id
- .as_ref()
- .and_then(|root_id| mutations.get(root_id))
- .expect("root proposal selected from the validated mutation map");
- projection.buyer_pubkey = Some(root.buyer_pubkey);
- projection.seller_pubkey = Some(root.seller_pubkey);
- projection.farm_id = Some(root.farm_id.clone());
- }
-
- for (proposal_mutation_id, decision_ids) in &decisions_by_proposal {
- let unique: BTreeSet<MutationId> = 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,
- });
- 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,
- });
- set_conflict(
- &mut projection.conflict_state,
- RadrootsTradeConflictStateV1::DecisionConflict,
- );
- }
- }
- }
-
- 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 {
- let claim = claims
- .get(claim_id)
- .expect("active agreement identifiers originate from indexed claims");
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::PrivateTermsUnavailable {
- candidate_id: claim.candidate_id,
- });
- }
- }
- 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
-}
-
-fn apply_decision(
- application: DecisionApplication<'_>,
- candidates_by_proposal: &BTreeMap<MutationId, CandidateRecord>,
- claims: &mut BTreeMap<MutationId, RadrootsTradeAgreementClaimV1>,
- projection: &mut RadrootsTradeProjectionV1,
-) {
- let DecisionApplication {
- mutation_id,
- mutation,
- proposal_mutation_id,
- candidate_id,
- decision,
- } = application;
- let Some(candidate_record) = candidates_by_proposal.get(proposal_mutation_id) else {
- projection.missing_proposal_ids.push(*proposal_mutation_id);
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::MissingProposal {
- decision_mutation_id: *mutation_id,
- proposal_mutation_id: *proposal_mutation_id,
- });
- return;
- };
- if candidate_record.candidate.candidate_id.as_ref() != Some(candidate_id) {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::CandidateIdMismatch {
- decision_mutation_id: *mutation_id,
- proposal_mutation_id: *proposal_mutation_id,
- });
- set_conflict(
- &mut projection.conflict_state,
- RadrootsTradeConflictStateV1::DecisionConflict,
- );
- return;
- }
- if mutation.author_pubkey != candidate_record_author_counterparty(candidate_record, mutation) {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::DecisionAuthorMismatch {
- decision_mutation_id: *mutation_id,
- });
- return;
- }
- if !mutation.parent_mutation_ids.contains(proposal_mutation_id) {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::DecisionParentMissing {
- decision_mutation_id: *mutation_id,
- proposal_mutation_id: *proposal_mutation_id,
- });
- set_conflict(
- &mut projection.conflict_state,
- RadrootsTradeConflictStateV1::InvalidCausalChain,
- );
- }
- match decision {
- TradeDecisionV1::Accepted {
- reservation_assertion,
- } => {
- let Some(reservation) = reservation_assertion else {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::MissingSellerReservation {
- decision_mutation_id: *mutation_id,
- });
- return;
- };
- if validate_reservation(
- mutation_id,
- candidate_id,
- &candidate_record.candidate,
- reservation,
- projection,
- ) {
- claims.insert(
- *mutation_id,
- RadrootsTradeAgreementClaimV1 {
- claim_mutation_id: *mutation_id,
- proposal_mutation_id: candidate_record.proposal_mutation_id,
- candidate_id: *candidate_id,
- candidate_author_pubkey: candidate_record.author_pubkey,
- accepted_by_pubkey: mutation.author_pubkey,
- reservation_commitment: reservation.assertion_commitment.clone(),
- },
- );
- }
- }
- TradeDecisionV1::Declined { .. } => {}
- }
-}
-
-fn candidate_record_author_counterparty(
- candidate_record: &CandidateRecord,
- mutation: &TradeMutationEnvelopeV1,
-) -> PublicKey {
- if candidate_record.author_pubkey == mutation.buyer_pubkey {
- mutation.seller_pubkey
- } else {
- mutation.buyer_pubkey
- }
-}
-
-fn validate_reservation(
- decision_mutation_id: &MutationId,
- candidate_id: &CandidateId,
- candidate: &TradeCandidateTermsV1,
- reservation: &SellerReservationAssertionV1,
- projection: &mut RadrootsTradeProjectionV1,
-) -> bool {
- let mut valid = true;
- if &reservation.candidate_id != candidate_id {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::ReservationCandidateMismatch {
- decision_mutation_id: *decision_mutation_id,
- });
- valid = false;
- }
- if reservation.inventory_authority_id != candidate.seller_pubkey {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::ReservationAuthorityMismatch {
- decision_mutation_id: *decision_mutation_id,
- });
- set_conflict(
- &mut projection.conflict_state,
- RadrootsTradeConflictStateV1::InventoryAuthorityConflict,
- );
- valid = false;
- }
- if reservation.commitments.len() != candidate.lines.len() {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::ReservationLineMismatch {
- decision_mutation_id: *decision_mutation_id,
- });
- 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,
- });
- valid = false;
- break;
- }
- }
- valid
-}
-
-fn apply_agreement_state(
- projection: &mut RadrootsTradeProjectionV1,
- claims: &BTreeMap<MutationId, RadrootsTradeAgreementClaimV1>,
- mutations: &BTreeMap<MutationId, TradeMutationEnvelopeV1>,
- candidates_by_proposal: &BTreeMap<MutationId, 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);
- if projection.active_agreement_claim_ids == [*target_claim_id] {
- projection.agreement_state = RadrootsTradeAgreementStateV1::Cancelled;
- }
- } else {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::CancellationConflict {
- cancellation_mutation_id: cancellation.mutation_id,
- });
- projection.agreement_state = RadrootsTradeAgreementStateV1::Contested;
- set_conflict(
- &mut projection.conflict_state,
- RadrootsTradeConflictStateV1::CancellationConflict,
- );
- }
- }
- }
-}
-
-fn cancellation_without_claim(
- cancellations: &[CancellationRecord],
- candidates_by_proposal: &BTreeMap<MutationId, 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
- })
- })
- })
-}
-
-fn non_dominated_claim_ids(
- claims: &BTreeMap<MutationId, RadrootsTradeAgreementClaimV1>,
- mutations: &BTreeMap<MutationId, TradeMutationEnvelopeV1>,
-) -> Vec<MutationId> {
- let mut memo = BTreeMap::<MutationId, BTreeSet<MutationId>>::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()
-}
-
-fn compatible_claims(claims: &[&RadrootsTradeAgreementClaimV1]) -> bool {
- let first = claims
- .first()
- .expect("non-empty claims produce at least one non-dominated claim");
- claims.iter().all(|claim| {
- claim.candidate_id == first.candidate_id
- && claim.reservation_commitment == first.reservation_commitment
- })
-}
-
-fn ancestors_of(
- mutation_id: &MutationId,
- mutations: &BTreeMap<MutationId, TradeMutationEnvelopeV1>,
- memo: &mut BTreeMap<MutationId, BTreeSet<MutationId>>,
-) -> BTreeSet<MutationId> {
- 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);
- ancestors.extend(ancestors_of(parent, mutations, memo));
- }
- }
- memo.insert(*mutation_id, ancestors.clone());
- ancestors
-}
-
-fn apply_negotiation_state(
- projection: &mut RadrootsTradeProjectionV1,
- candidates_by_proposal: &BTreeMap<MutationId, CandidateRecord>,
- claims: &BTreeMap<MutationId, 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<MutationId, CandidateRecord>,
- private_terms: &[RadrootsTradePrivateTermsEvidenceV1],
-) -> RadrootsTradePrivateTermsStateV1 {
- let mut private_terms_by_candidate =
- BTreeMap::<CandidateId, RadrootsTradePrivateTermsStateV1>::new();
- for record in private_terms {
- private_terms_by_candidate
- .entry(record.candidate_id)
- .and_modify(|state| *state = (*state).max(record.state))
- .or_insert(record.state);
- }
- let mut required_states = Vec::new();
- for claim in &projection.agreement_claims {
- let candidate_record = candidates_by_proposal
- .get(&claim.proposal_mutation_id)
- .expect("agreement claims originate from indexed candidates");
- 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.contains(&RadrootsTradePrivateTermsStateV1::CommitmentMismatch) {
- RadrootsTradePrivateTermsStateV1::CommitmentMismatch
- } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::Undecryptable) {
- RadrootsTradePrivateTermsStateV1::Undecryptable
- } else if required_states.contains(&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<MutationId, TradeMutationEnvelopeV1>,
-) -> Vec<CandidateId> {
- mutations
- .values()
- .filter_map(|mutation| match &mutation.body {
- TradeMutationBodyV1::Decision {
- candidate_id,
- decision: TradeDecisionV1::Declined { .. },
- ..
- }
- | TradeMutationBodyV1::RevisionDecision {
- candidate_id,
- decision: TradeDecisionV1::Declined { .. },
- ..
- } => Some(*candidate_id),
- _ => None,
- })
- .collect()
-}
-
-fn is_acceptance(body: &TradeMutationBodyV1) -> bool {
- matches!(
- body,
- TradeMutationBodyV1::Decision {
- decision: TradeDecisionV1::Accepted { .. },
- ..
- } | TradeMutationBodyV1::RevisionDecision {
- decision: TradeDecisionV1::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) -> Result<String, String> {
- let mut digest_input = projection.clone();
- digest_input.projection_digest.clear();
- let value = serde_json::to_value(&digest_input)
- .map_err(|error| format!("projection serialization failed: {error}"))?;
- let canonical = radroots_event::trade::canonical_jcs_value(&value)
- .map_err(|error| format!("projection canonicalization failed: {error}"))?;
- let mut hasher = Sha256::new();
- hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN);
- hasher.update(canonical.as_bytes());
- Ok(hex::encode(hasher.finalize()))
-}
-
-#[cfg(not(feature = "serde_json"))]
-fn projection_digest(_projection: &RadrootsTradeProjectionV1) -> Result<String, String> {
- Err("projection digest requires the serde_json feature".to_string())
-}
-
-#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
-mod tests {
- use super::*;
- use radroots_event::{
- id::{ClassifiedListingAddress, InventoryBinId},
- trade::{
- FulfillmentProfileV1, RADROOTS_TRADE_DECISION_CONTRACT_ID,
- RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID,
- RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, SellerReservationLineV1,
- TradeCancellationProfileV1, TradeCandidateLineV1, TradeEconomicAdjustmentV1,
- TradeEconomicsProfileV1, TradeLineTombstoneV1, TradePrivateTermsRefV1,
- canonical_trade_mutation_content,
- },
- };
- use radroots_test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_BOB_PUBLIC_KEY_HEX};
-
- const REDUCER_VECTORS: &str =
- include_str!("../../../contracts/conformance/vectors/trade/reduce_records.v1.json");
-
- fn hex_64(character: char) -> String {
- core::iter::repeat_n(character, 64).collect()
- }
-
- fn hex_32(character: char) -> String {
- core::iter::repeat_n(character, 32).collect()
- }
-
- fn pubkey(character: char) -> PublicKey {
- let public_key_hex = match character {
- 'a' => FIXTURE_ALICE_PUBLIC_KEY_HEX,
- 'b' => FIXTURE_BOB_PUBLIC_KEY_HEX,
- _ => panic!("unsupported fixture public key label: {character}"),
- };
- PublicKey::from_hex(public_key_hex).expect("fixture pubkey")
- }
-
- fn event_id(character: char) -> EventId {
- EventId::parse(hex_64(character)).unwrap()
- }
-
- fn trade_id() -> TradeId {
- TradeId::parse(hex_32('1')).unwrap()
- }
-
- fn dtag(value: &str) -> DTag {
- DTag::parse(value).unwrap()
- }
-
- fn bin_id(value: &str) -> InventoryBinId {
- InventoryBinId::parse(value).unwrap()
- }
-
- fn candidate(line_suffix: &str) -> TradeCandidateTermsV1 {
- TradeCandidateTermsV1 {
- 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![TradeCandidateLineV1 {
- line_id: dtag(&format!("line-{line_suffix}")),
- listing_addr: ClassifiedListingAddress::parse(format!(
- "30402:{}:listing-{line_suffix}",
- FIXTURE_BOB_PUBLIC_KEY_HEX
- ))
- .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,
- }],
- line_tombstones: Vec::<TradeLineTombstoneV1>::new(),
- economics: TradeEconomicsProfileV1 {
- 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::<TradeEconomicAdjustmentV1>::new(),
- },
- fulfillment: FulfillmentProfileV1 {
- 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: TradeCancellationProfileV1 {
- 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 proposal() -> TradeMutationEnvelopeV1 {
- canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
- 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: TradeMutationBodyV1::Proposal {
- candidate: candidate("1"),
- },
- })
- .unwrap()
- .envelope
- }
-
- fn reservation(
- candidate: &TradeCandidateTermsV1,
- marker: char,
- ) -> SellerReservationAssertionV1 {
- SellerReservationAssertionV1 {
- reservation_id: dtag(&format!("reservation-{marker}")),
- inventory_authority_id: candidate.seller_pubkey,
- inventory_epoch: 42,
- candidate_id: candidate.candidate_id.unwrap(),
- commitments: candidate
- .lines
- .iter()
- .map(|line| SellerReservationLineV1 {
- 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(
- proposal: &TradeMutationEnvelopeV1,
- marker: char,
- ) -> TradeMutationEnvelopeV1 {
- let proposal_id = proposal.mutation_id.unwrap();
- let candidate = match &proposal.body {
- TradeMutationBodyV1::Proposal { candidate }
- | TradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(),
- _ => unreachable!(),
- };
- canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
- 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],
- author_pubkey: pubkey('b'),
- counterparty_pubkey: pubkey('a'),
- authored_at_unix_s: u64::from(marker),
- body: TradeMutationBodyV1::Decision {
- proposal_mutation_id: proposal_id,
- candidate_id: candidate.candidate_id.unwrap(),
- decision: TradeDecisionV1::Accepted {
- reservation_assertion: Some(reservation(&candidate, marker)),
- },
- },
- })
- .unwrap()
- .envelope
- }
-
- fn declined_decision(proposal: &TradeMutationEnvelopeV1) -> TradeMutationEnvelopeV1 {
- let proposal_id = proposal.mutation_id.unwrap();
- let candidate = match &proposal.body {
- TradeMutationBodyV1::Proposal { candidate }
- | TradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(),
- _ => unreachable!(),
- };
- canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
- 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],
- author_pubkey: pubkey('b'),
- counterparty_pubkey: pubkey('a'),
- authored_at_unix_s: 102,
- body: TradeMutationBodyV1::Decision {
- proposal_mutation_id: proposal_id,
- candidate_id: candidate.candidate_id.unwrap(),
- decision: TradeDecisionV1::Declined {
- reason: "unavailable".to_string(),
- },
- },
- })
- .unwrap()
- .envelope
- }
-
- fn revision_proposal(
- root: &TradeMutationEnvelopeV1,
- parents: Vec<MutationId>,
- ) -> TradeMutationEnvelopeV1 {
- let mut parents = parents;
- parents.sort();
- canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
- 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: TradeMutationBodyV1::RevisionProposal {
- candidate: candidate("2"),
- },
- })
- .unwrap()
- .envelope
- }
-
- fn revision_acceptance(
- root: &TradeMutationEnvelopeV1,
- proposal: &TradeMutationEnvelopeV1,
- ) -> TradeMutationEnvelopeV1 {
- let proposal_id = proposal.mutation_id.unwrap();
- let candidate = match &proposal.body {
- TradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(),
- _ => unreachable!(),
- };
- canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
- 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],
- author_pubkey: pubkey('b'),
- counterparty_pubkey: pubkey('a'),
- authored_at_unix_s: 201,
- body: TradeMutationBodyV1::RevisionDecision {
- proposal_mutation_id: proposal_id,
- candidate_id: candidate.candidate_id.unwrap(),
- decision: TradeDecisionV1::Accepted {
- reservation_assertion: Some(reservation(&candidate, '9')),
- },
- },
- })
- .unwrap()
- .envelope
- }
-
- fn cancellation(
- root: &TradeMutationEnvelopeV1,
- target_claim: MutationId,
- parent: MutationId,
- ) -> TradeMutationEnvelopeV1 {
- canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
- 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: TradeMutationBodyV1::Cancellation {
- target_candidate_id: None,
- target_claim_mutation_id: Some(target_claim),
- reason: "before cutoff".to_string(),
- },
- })
- .unwrap()
- .envelope
- }
-
- fn root_id(envelope: &TradeMutationEnvelopeV1) -> MutationId {
- envelope.mutation_id.unwrap()
- }
-
- fn record(mutation: TradeMutationEnvelopeV1) -> RadrootsTradeMutationRecordV1 {
- RadrootsTradeMutationRecordV1 {
- transport_event_id: None,
- mutation,
- }
- }
-
- fn reduce(mutations: Vec<TradeMutationEnvelopeV1>) -> RadrootsTradeProjectionV1 {
- let mut input = RadrootsTradeReductionInputV1::new(trade_id());
- input.mutations = mutations.into_iter().map(record).collect();
- reduce_trade_records(input)
- }
-
- fn recanonicalize(mut mutation: TradeMutationEnvelopeV1) -> TradeMutationEnvelopeV1 {
- mutation.mutation_id = None;
- canonical_trade_mutation_content(mutation)
- .expect("recanonicalized mutation")
- .envelope
- }
-
- fn candidate_cancellation(root: &TradeMutationEnvelopeV1) -> TradeMutationEnvelopeV1 {
- let candidate_id = match &root.body {
- TradeMutationBodyV1::Proposal { candidate } => {
- candidate.candidate_id.expect("candidate id")
- }
- _ => unreachable!(),
- };
- canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
- 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![root_id(root)],
- author_pubkey: pubkey('a'),
- counterparty_pubkey: pubkey('b'),
- authored_at_unix_s: 301,
- body: TradeMutationBodyV1::Cancellation {
- target_candidate_id: Some(candidate_id),
- target_claim_mutation_id: None,
- reason: "cancel before agreement".to_string(),
- },
- })
- .expect("candidate cancellation")
- .envelope
- }
-
- #[test]
- 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,
- "35a5f555344febe675f4e5e6b15865356400b2b8f7791fc631590e0f1e1fd441"
- );
- assert_eq!(first.projection_digest, second.projection_digest);
- assert_eq!(first.active_agreement_claim_ids.len(), 1);
- }
- #[test]
- fn reducer_projection_is_identical_for_every_three_record_permutation() {
- let proposal = proposal();
- let decision = accepted_decision(&proposal, '1');
- let cancellation = cancellation(&proposal, root_id(&decision), root_id(&decision));
- let permutations = [
- vec![proposal.clone(), decision.clone(), cancellation.clone()],
- vec![proposal.clone(), cancellation.clone(), decision.clone()],
- vec![decision.clone(), proposal.clone(), cancellation.clone()],
- vec![decision.clone(), cancellation.clone(), proposal.clone()],
- vec![cancellation.clone(), proposal.clone(), decision.clone()],
- vec![cancellation, decision, proposal],
- ];
- let expected = reduce(permutations[0].clone());
-
- for permutation in permutations.into_iter().skip(1) {
- assert_eq!(reduce(permutation), expected);
- }
- }
-
- #[test]
- fn reducer_excludes_unsupported_versions_from_domain_semantics() {
- let mut unsupported = proposal();
- unsupported.schema_version += 1;
- let unsupported_id = root_id(&unsupported);
-
- let projection = reduce(vec![unsupported]);
-
- assert_eq!(
- projection.evidence_state,
- RadrootsTradeEvidenceStateV1::UnsupportedVersion
- );
- assert_eq!(
- projection.negotiation_state,
- RadrootsTradeNegotiationStateV1::None
- );
- assert_eq!(
- projection.agreement_state,
- RadrootsTradeAgreementStateV1::None
- );
- assert_eq!(projection.root_mutation_id, None);
- assert_eq!(projection.unsupported_mutation_ids, vec![unsupported_id]);
- }
-
- #[test]
- fn reducer_private_evidence_precedence_is_permutation_independent() {
- let mut root = proposal();
- if let TradeMutationBodyV1::Proposal { candidate } = &mut root.body {
- candidate.private_terms = Some(TradePrivateTermsRefV1 {
- artifact_id: "artifact-1".to_string(),
- schema_id: "radroots.private.fulfillment.v1".to_string(),
- ciphertext_commitment: hex_64('f'),
- required_acknowledgement: true,
- });
- }
- let root = recanonicalize(root);
- let candidate_id = match &root.body {
- TradeMutationBodyV1::Proposal { candidate } => candidate.candidate_id.unwrap(),
- _ => unreachable!(),
- };
- let decision = accepted_decision(&root, '1');
- let evidence = |state| RadrootsTradePrivateTermsEvidenceV1::new(candidate_id, state);
- let reduce_with = |private_terms| {
- RadrootsTradeReductionInputV1::new(trade_id())
- .with_mutations(vec![record(root.clone()), record(decision.clone())])
- .with_private_terms(private_terms)
- };
-
- let first = reduce_trade_records(reduce_with(vec![
- evidence(RadrootsTradePrivateTermsStateV1::AvailableVerified),
- evidence(RadrootsTradePrivateTermsStateV1::CommitmentMismatch),
- ]));
- let second = reduce_trade_records(reduce_with(vec![
- evidence(RadrootsTradePrivateTermsStateV1::CommitmentMismatch),
- evidence(RadrootsTradePrivateTermsStateV1::AvailableVerified),
- ]));
-
- assert_eq!(
- first.private_terms_state,
- RadrootsTradePrivateTermsStateV1::CommitmentMismatch
- );
- assert_eq!(first, second);
- }
-
- #[test]
- fn reducer_attestation_order_and_duplicates_do_not_change_digest() {
- let root = proposal();
- let decision = accepted_decision(&root, '1');
- let valid = RadrootsTradeAttestationRecordV1::new(
- event_id('8'),
- root_id(&decision),
- RadrootsTradeAttestationResultV1::Valid,
- );
- let invalid = RadrootsTradeAttestationRecordV1::new(
- event_id('9'),
- root_id(&decision),
- RadrootsTradeAttestationResultV1::Invalid,
- );
- let reduce_with = |attestations| {
- reduce_trade_records(
- RadrootsTradeReductionInputV1::new(trade_id())
- .with_mutations(vec![record(root.clone()), record(decision.clone())])
- .with_attestations(attestations),
- )
- };
-
- let first = reduce_with(vec![valid.clone(), invalid.clone(), valid.clone()]);
- let second = reduce_with(vec![invalid, valid]);
-
- assert_eq!(
- first.attestation_state,
- RadrootsTradeAttestationStateV1::Conflicting
- );
- assert_eq!(first.attestations.len(), 2);
- assert_eq!(first, second);
- }
-
- #[test]
- fn reducer_conformance_inventory_covers_deterministic_edge_cases() {
- let document: serde_json::Value =
- serde_json::from_str(REDUCER_VECTORS).expect("reducer vectors parse");
- let ids = document["vectors"]
- .as_array()
- .expect("reducer vector array")
- .iter()
- .map(|vector| vector["id"].as_str().expect("reducer vector id"))
- .collect::<BTreeSet<_>>();
-
- for required in [
- "trade_reduce_agreed_projection_digest_001",
- "trade_reduce_contested_claims_002",
- "trade_reduce_attestation_only_003",
- "trade_reduce_missing_parent_004",
- "trade_reduce_unsupported_version_isolated_005",
- "trade_reduce_private_evidence_precedence_006",
- "trade_reduce_attestation_deduplication_007",
- ] {
- assert!(ids.contains(required), "missing reducer vector {required}");
- }
- }
-
- #[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.agreement_state,
- RadrootsTradeAgreementStateV1::Contested
- );
- 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 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 reducer_keeps_missing_parents_as_incomplete_evidence() {
- let proposal = proposal();
- let mut decision = accepted_decision(&proposal, '1');
- let missing_parent = MutationId::parse(hex_64('e')).unwrap();
- decision.parent_mutation_ids = vec![missing_parent];
- let decision = canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
- mutation_id: None,
- ..decision
- })
- .unwrap()
- .envelope;
- let projection = reduce(vec![proposal, decision]);
-
- 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 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 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 reducer_tracks_private_terms_without_hiding_claims() {
- let mut root = proposal();
- if let TradeMutationBodyV1::Proposal { candidate } = &mut root.body {
- candidate.private_terms = Some(TradePrivateTermsRefV1 {
- 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(TradeMutationEnvelopeV1 {
- mutation_id: None,
- ..root
- })
- .unwrap()
- .envelope;
- let decision = accepted_decision(&root, '1');
- let projection = reduce(vec![root, decision]);
-
- assert_eq!(
- projection.agreement_state,
- RadrootsTradeAgreementStateV1::Agreed
- );
- assert_eq!(
- projection.private_terms_state,
- RadrootsTradePrivateTermsStateV1::Missing
- );
- assert_eq!(projection.active_agreement_claim_ids.len(), 1);
- }
-
- #[test]
- 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
- );
- assert_eq!(
- declined_projection.agreement_state,
- RadrootsTradeAgreementStateV1::None
- );
-
- 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
- );
- assert_eq!(
- expired_projection.agreement_state,
- RadrootsTradeAgreementStateV1::None
- );
- }
-
- #[test]
- fn reducer_classifies_malformed_unsupported_and_foreign_records() {
- let empty = reduce(Vec::new());
- assert_eq!(
- empty.negotiation_state,
- RadrootsTradeNegotiationStateV1::None
- );
- assert_eq!(empty.evidence_state, RadrootsTradeEvidenceStateV1::Missing);
- assert!(
- empty
- .issues
- .contains(&RadrootsTradeReducerIssueV1::MissingRootProposal)
- );
-
- let mut missing_id = proposal();
+ let mut missing_id = all_operation_mutations().remove(0);
missing_id.mutation_id = None;
- let mut unsupported = proposal();
- unsupported.schema_version += 1;
- let unsupported_id = root_id(&unsupported);
- let mut invalid = proposal();
- invalid.contract_id = "invalid.contract".to_string();
- let mut second_root = proposal();
- second_root.authored_at_unix_s += 1;
- let second_root = recanonicalize(second_root);
- let projection = reduce(vec![
- missing_id.clone(),
- missing_id,
- unsupported,
- invalid,
- proposal(),
- second_root,
- ]);
- assert!(
- projection
- .issues
- .contains(&RadrootsTradeReducerIssueV1::MissingMutationId)
- );
- assert!(projection.issues.iter().any(|issue| matches!(
- issue,
- RadrootsTradeReducerIssueV1::UnsupportedSchema { mutation_id, .. }
- if mutation_id == &unsupported_id
- )));
- assert!(
- projection
- .issues
- .iter()
- .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::InvalidMutation { .. }))
- );
- assert!(
- projection
- .issues
- .contains(&RadrootsTradeReducerIssueV1::MultipleRootProposals)
- );
- assert_eq!(
- projection.conflict_state,
- RadrootsTradeConflictStateV1::ConcurrentCandidates
- );
-
- let foreign_trade = TradeId::parse(hex_32('2')).expect("foreign trade");
- let mut input = RadrootsTradeReductionInputV1::new(foreign_trade);
- input.mutations = vec![record(proposal())];
- let foreign = reduce_trade_records(input);
- assert!(foreign.issues.iter().any(|issue| matches!(
- issue,
- RadrootsTradeReducerIssueV1::TradeIdentityMismatch { .. }
- )));
- }
-
- #[test]
- fn reducer_rejects_invalid_decision_relationships() {
- let root = proposal();
-
- let missing_proposal = reduce(vec![accepted_decision(&root, '1')]);
- assert!(
- missing_proposal
- .issues
- .iter()
- .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::MissingProposal { .. }))
- );
-
- let mut wrong_candidate = accepted_decision(&root, '1');
- if let TradeMutationBodyV1::Decision { candidate_id, .. } = &mut wrong_candidate.body {
- *candidate_id = CandidateId::parse(hex_64('f')).expect("candidate id");
- }
- let wrong_candidate = recanonicalize(wrong_candidate);
- let projection = reduce(vec![root.clone(), wrong_candidate]);
- assert!(projection.issues.iter().any(|issue| matches!(
- issue,
- RadrootsTradeReducerIssueV1::CandidateIdMismatch { .. }
- )));
-
- let mut wrong_author = accepted_decision(&root, '2');
- wrong_author.author_pubkey = wrong_author.buyer_pubkey;
- wrong_author.counterparty_pubkey = wrong_author.seller_pubkey;
- let wrong_author = recanonicalize(wrong_author);
- let projection = reduce(vec![root.clone(), wrong_author]);
- assert!(projection.issues.iter().any(|issue| matches!(
- issue,
- RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { .. }
- )));
-
- let mut no_reservation = accepted_decision(&root, '3');
- if let TradeMutationBodyV1::Decision { decision, .. } = &mut no_reservation.body {
- *decision = TradeDecisionV1::Accepted {
- reservation_assertion: None,
- };
- }
- let no_reservation = recanonicalize(no_reservation);
- let projection = reduce(vec![root, no_reservation]);
- assert!(projection.issues.iter().any(|issue| matches!(
- issue,
- RadrootsTradeReducerIssueV1::MissingSellerReservation { .. }
- )));
- }
-
- #[test]
- fn reducer_rejects_every_reservation_mismatch() {
- let root = proposal();
- let mut mismatched = accepted_decision(&root, '4');
- if let TradeMutationBodyV1::Decision {
- decision:
- TradeDecisionV1::Accepted {
- reservation_assertion: Some(reservation),
- },
- ..
- } = &mut mismatched.body
- {
- reservation.candidate_id = CandidateId::parse(hex_64('f')).expect("candidate id");
- reservation.inventory_authority_id = pubkey('a');
- reservation.commitments[0].unit_code = "kg".to_string();
- }
- let mismatched = recanonicalize(mismatched);
- let projection = reduce(vec![root.clone(), mismatched]);
- assert!(projection.issues.iter().any(|issue| matches!(
- issue,
- RadrootsTradeReducerIssueV1::ReservationCandidateMismatch { .. }
- )));
- assert!(projection.issues.iter().any(|issue| matches!(
- issue,
- RadrootsTradeReducerIssueV1::ReservationAuthorityMismatch { .. }
- )));
- assert!(projection.issues.iter().any(|issue| matches!(
- issue,
- RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. }
- )));
- assert_eq!(
- projection.conflict_state,
- RadrootsTradeConflictStateV1::InventoryAuthorityConflict
- );
-
- let mut wrong_count = accepted_decision(&root, '5');
- if let TradeMutationBodyV1::Decision {
- decision:
- TradeDecisionV1::Accepted {
- reservation_assertion: Some(reservation),
- },
- ..
- } = &mut wrong_count.body
- {
- let mut extra = reservation.commitments[0].clone();
- extra.line_id = dtag("line-2");
- reservation.commitments.push(extra);
- }
- let wrong_count = recanonicalize(wrong_count);
- let projection = reduce(vec![root, wrong_count]);
- assert!(projection.issues.iter().any(|issue| matches!(
- issue,
- RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. }
- )));
- }
-
- #[test]
- fn reducer_covers_decision_conflict_and_ordered_cancellation() {
- let root = proposal();
- let accepted = accepted_decision(&root, '1');
- let declined = declined_decision(&root);
- let conflicted = reduce(vec![root.clone(), accepted.clone(), declined]);
- assert_eq!(
- conflicted.conflict_state,
- RadrootsTradeConflictStateV1::DecisionConflict
- );
- assert!(
- conflicted
- .issues
- .iter()
- .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::DecisionConflict { .. }))
- );
-
- let cancel = cancellation(&root, root_id(&accepted), root_id(&accepted));
- let cancelled = reduce(vec![root, accepted.clone(), cancel]);
- assert_eq!(
- cancelled.agreement_state,
- RadrootsTradeAgreementStateV1::Cancelled
- );
- assert_eq!(cancelled.cancelled_claim_ids, vec![root_id(&accepted)]);
- }
-
- #[test]
- fn reducer_covers_pre_agreement_cancellation_and_requested_evidence() {
- let root = proposal();
- let cancel = candidate_cancellation(&root);
- let cancelled = reduce(vec![root.clone(), cancel]);
assert_eq!(
- cancelled.agreement_state,
- RadrootsTradeAgreementStateV1::Cancelled
+ WorkflowPlan::prepare(missing_id).unwrap_err().kind(),
+ ErrorKind::MissingMutationId
);
- let decision = accepted_decision(&root, '1');
- let mut input = RadrootsTradeReductionInputV1::new(trade_id());
- input.mutations = vec![record(root), record(decision)];
- input.evidence_state = RadrootsTradeEvidenceStateV1::QueryPartial;
- assert_eq!(
- reduce_trade_records(input).evidence_state,
- RadrootsTradeEvidenceStateV1::QueryPartial
- );
- }
-
- #[test]
- fn reducer_covers_private_terms_and_attestation_precedence() {
- let mut root = proposal();
- if let TradeMutationBodyV1::Proposal { candidate } = &mut root.body {
- candidate.private_terms = Some(TradePrivateTermsRefV1 {
- artifact_id: "artifact-1".to_string(),
- schema_id: "radroots.private.fulfillment.v1".to_string(),
- ciphertext_commitment: hex_64('f'),
- required_acknowledgement: true,
- });
- }
- let root = recanonicalize(root);
- let candidate_id = match &root.body {
- TradeMutationBodyV1::Proposal { candidate } => {
- candidate.candidate_id.expect("candidate id")
- }
- _ => unreachable!(),
- };
- let decision = accepted_decision(&root, '1');
- for (state, expected) in [
- (
- RadrootsTradePrivateTermsStateV1::AvailableVerified,
- RadrootsTradePrivateTermsStateV1::AvailableVerified,
- ),
- (
- RadrootsTradePrivateTermsStateV1::Undecryptable,
- RadrootsTradePrivateTermsStateV1::Undecryptable,
- ),
- (
- RadrootsTradePrivateTermsStateV1::CommitmentMismatch,
- RadrootsTradePrivateTermsStateV1::CommitmentMismatch,
- ),
- ] {
- let mut input = RadrootsTradeReductionInputV1::new(trade_id());
- input.mutations = vec![record(root.clone()), record(decision.clone())];
- input.private_terms = vec![RadrootsTradePrivateTermsEvidenceV1 {
- candidate_id,
- state,
- }];
- assert_eq!(reduce_trade_records(input).private_terms_state, expected);
- }
-
- for (results, expected) in [
- (
- vec![RadrootsTradeAttestationResultV1::Valid],
- RadrootsTradeAttestationStateV1::PresentValid,
- ),
- (
- vec![
- RadrootsTradeAttestationResultV1::Valid,
- RadrootsTradeAttestationResultV1::Invalid,
- ],
- RadrootsTradeAttestationStateV1::Conflicting,
- ),
- ] {
- let mut input = RadrootsTradeReductionInputV1::new(trade_id());
- input.mutations = vec![record(root.clone()), record(decision.clone())];
- input.attestations = results
- .into_iter()
- .enumerate()
- .map(|(index, result)| RadrootsTradeAttestationRecordV1 {
- event_id: event_id(if index == 0 { '8' } else { '9' }),
- claim_mutation_id: root_id(&decision),
- result,
- })
- .collect();
- assert_eq!(reduce_trade_records(input).attestation_state, expected);
- }
- }
-
- #[test]
- fn reducer_private_helpers_cover_empty_graph_and_conflict_precedence() {
- let missing = MutationId::parse(hex_64('e')).expect("mutation id");
- assert!(ancestors_of(&missing, &BTreeMap::new(), &mut BTreeMap::new()).is_empty());
-
- let mut conflict = RadrootsTradeConflictStateV1::DecisionConflict;
- set_conflict(
- &mut conflict,
- RadrootsTradeConflictStateV1::ConcurrentCandidates,
- );
- assert_eq!(conflict, RadrootsTradeConflictStateV1::DecisionConflict);
- set_conflict(
- &mut conflict,
- RadrootsTradeConflictStateV1::InvalidCausalChain,
- );
- assert_eq!(conflict, RadrootsTradeConflictStateV1::InvalidCausalChain);
- }
-
- #[test]
- fn reducer_rejects_self_identified_decision_author_bypass() {
- let root = proposal();
- let mut decision = accepted_decision(&root, '7');
- decision.author_pubkey = decision.buyer_pubkey;
- decision.counterparty_pubkey = decision.buyer_pubkey;
- let decision = recanonicalize(decision);
-
- let projection = reduce(vec![root, decision]);
- assert!(projection.issues.iter().any(|issue| matches!(
- issue,
- RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { .. }
- )));
- }
-
- #[test]
- fn reducer_covers_all_decline_and_counterparty_shapes() {
- let root = proposal();
- let first = declined_decision(&root);
- let mut second = first.clone();
- second.authored_at_unix_s += 1;
- if let TradeMutationBodyV1::Decision {
- decision: TradeDecisionV1::Declined { reason },
- ..
- } = &mut second.body
- {
- *reason = "still unavailable".to_string();
- }
- let second = recanonicalize(second);
- let projection = reduce(vec![root.clone(), first, second]);
- assert_eq!(
- projection.negotiation_state,
- RadrootsTradeNegotiationStateV1::ClosedDeclined
- );
-
- let revision = revision_proposal(&root, vec![root_id(&root)]);
- let mut revision_decline = declined_decision(&revision);
- let TradeMutationBodyV1::Decision {
- proposal_mutation_id,
- candidate_id,
- decision,
- } = revision_decline.body
- else {
- unreachable!();
- };
- revision_decline.contract_id = RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID.to_string();
- revision_decline.root_mutation_id = Some(root_id(&root));
- revision_decline.body = TradeMutationBodyV1::RevisionDecision {
- proposal_mutation_id,
- candidate_id,
- decision,
- };
- let revision_decline = recanonicalize(revision_decline);
- assert_eq!(
- declined_candidate_ids(&BTreeMap::from([(
- root_id(&revision_decline),
- revision_decline,
- )])),
- vec![candidate_id]
- );
-
- let candidate = match &root.body {
- TradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
- _ => unreachable!(),
- };
- let mut candidate_record = CandidateRecord {
- proposal_mutation_id: root_id(&root),
- author_pubkey: root.buyer_pubkey,
- candidate,
- };
- let decision = accepted_decision(&root, '8');
- assert_eq!(
- candidate_record_author_counterparty(&candidate_record, &decision),
- decision.seller_pubkey
- );
- candidate_record.author_pubkey = decision.seller_pubkey;
- assert_eq!(
- candidate_record_author_counterparty(&candidate_record, &decision),
- decision.buyer_pubkey
- );
- }
-
- #[test]
- fn reservation_line_validation_checks_each_field() {
- let root = proposal();
- let candidate = match &root.body {
- TradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
- _ => unreachable!(),
- };
- let candidate_id = candidate.candidate_id.expect("candidate id");
- let decision_mutation_id = root_id(&accepted_decision(&root, '9'));
-
- for field in 0..5 {
- let mut reservation = reservation(&candidate, '9');
- match field {
- 0 => reservation.commitments[0].line_id = dtag("line-other"),
- 1 => reservation.commitments[0].bin_id = bin_id("bin-other"),
- 2 => reservation.commitments[0].quantity_mantissa = "3".to_string(),
- 3 => reservation.commitments[0].quantity_scale = 1,
- 4 => reservation.commitments[0].unit_code = "kg".to_string(),
- _ => unreachable!(),
- }
- let mut projection = RadrootsTradeProjectionV1::empty(trade_id());
- assert!(!validate_reservation(
- &decision_mutation_id,
- &candidate_id,
- &candidate,
- &reservation,
- &mut projection,
- ));
- assert!(projection.issues.iter().any(|issue| matches!(
- issue,
- RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. }
- )));
- }
- }
-
- #[test]
- fn agreement_and_cancellation_helpers_cover_nonterminal_shapes() {
- let root = proposal();
- let first_decision = accepted_decision(&root, '1');
- let second_decision = accepted_decision(&root, '2');
- let first_id = root_id(&first_decision);
- let second_id = root_id(&second_decision);
- let candidate = match &root.body {
- TradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
- _ => unreachable!(),
- };
- let candidate_id = candidate.candidate_id.expect("candidate id");
- let claim = |claim_mutation_id: MutationId| RadrootsTradeAgreementClaimV1 {
- claim_mutation_id,
- proposal_mutation_id: root_id(&root),
- candidate_id,
- candidate_author_pubkey: root.buyer_pubkey,
- accepted_by_pubkey: root.seller_pubkey,
- reservation_commitment: hex_64('a'),
- };
- let claims = BTreeMap::from([(first_id, claim(first_id)), (second_id, claim(second_id))]);
- let mutations = BTreeMap::from([(first_id, first_decision), (second_id, second_decision)]);
- let missing_claim = MutationId::parse(hex_64('e')).expect("missing claim");
- let cancellations = vec![
- CancellationRecord {
- mutation_id: MutationId::parse(hex_64('3')).expect("cancellation"),
- parent_mutation_ids: Vec::new(),
- target_candidate_id: None,
- target_claim_mutation_id: None,
- },
- CancellationRecord {
- mutation_id: MutationId::parse(hex_64('4')).expect("cancellation"),
- parent_mutation_ids: Vec::new(),
- target_candidate_id: None,
- target_claim_mutation_id: Some(missing_claim),
- },
- CancellationRecord {
- mutation_id: MutationId::parse(hex_64('5')).expect("cancellation"),
- parent_mutation_ids: vec![first_id],
- target_candidate_id: None,
- target_claim_mutation_id: Some(first_id),
- },
- ];
- let mut projection = RadrootsTradeProjectionV1::empty(trade_id());
- apply_agreement_state(
- &mut projection,
- &claims,
- &mutations,
- &BTreeMap::new(),
- &cancellations,
- );
- assert_eq!(
- projection.agreement_state,
- RadrootsTradeAgreementStateV1::Agreed
- );
- assert_eq!(projection.cancelled_claim_ids, vec![first_id]);
-
- let mut incompatible_claims = claims.clone();
- incompatible_claims
- .get_mut(&second_id)
- .expect("second claim")
- .candidate_id = CandidateId::parse(hex_64('f')).expect("candidate");
- let mut incompatible = RadrootsTradeProjectionV1::empty(trade_id());
- apply_agreement_state(
- &mut incompatible,
- &incompatible_claims,
- &mutations,
- &BTreeMap::new(),
- &[],
- );
- assert_eq!(
- incompatible.conflict_state,
- RadrootsTradeConflictStateV1::DecisionConflict
- );
-
- let candidates = BTreeMap::from([(
- root_id(&root),
- CandidateRecord {
- proposal_mutation_id: root_id(&root),
- author_pubkey: root.author_pubkey,
- candidate: candidate.clone(),
- },
- )]);
- let unknown_candidate = CandidateId::parse(hex_64('f')).expect("candidate");
- assert!(!cancellation_without_claim(
- &[CancellationRecord {
- mutation_id: MutationId::parse(hex_64('6')).expect("cancellation"),
- parent_mutation_ids: Vec::new(),
- target_candidate_id: Some(unknown_candidate),
- target_claim_mutation_id: None,
- }],
- &candidates,
- ));
- let mut disabled_candidate = candidate;
- disabled_candidate.cancellation.buyer_pre_agreement = false;
- let disabled_candidates = BTreeMap::from([(
- root_id(&root),
- CandidateRecord {
- proposal_mutation_id: root_id(&root),
- author_pubkey: root.author_pubkey,
- candidate: disabled_candidate,
- },
- )]);
- assert!(!cancellation_without_claim(
- &[CancellationRecord {
- mutation_id: MutationId::parse(hex_64('7')).expect("cancellation"),
- parent_mutation_ids: Vec::new(),
- target_candidate_id: Some(candidate_id),
- target_claim_mutation_id: None,
- }],
- &disabled_candidates,
- ));
- }
-
- #[test]
- fn evidence_state_covers_missing_proposal_independently() {
- let root = proposal();
- let mut projection = RadrootsTradeProjectionV1::empty(trade_id());
- projection.root_mutation_id = Some(root_id(&root));
- projection
- .missing_proposal_ids
- .push(MutationId::parse(hex_64('e')).expect("proposal"));
+ let mut invalid = all_operation_mutations().remove(1);
+ invalid.parent_mutation_ids.clear();
assert_eq!(
- reduce_evidence_state(&projection, RadrootsTradeEvidenceStateV1::Complete),
- RadrootsTradeEvidenceStateV1::Missing
+ WorkflowPlan::prepare(invalid).unwrap_err().kind(),
+ ErrorKind::InvalidMutation
);
}
}
diff --git a/crates/trade/tests/package_boundary.rs b/crates/trade/tests/package_boundary.rs
@@ -8,7 +8,8 @@ const DRAFT: &str = include_str!("../src/operational_listing/draft.rs");
const IDENTITY: &str = include_str!("../src/identity.rs");
const MODEL: &str = include_str!("../src/model.rs");
const ROOT: &str = include_str!("../src/lib.rs");
-const TRADE_CONTRACT: &str = include_str!("../src/workflow.rs");
+const REDUCER_IMPLEMENTATION: &str = include_str!("../src/reducer_impl.rs");
+const WORKFLOW: &str = include_str!("../src/workflow.rs");
const PACKAGE_TIERS: &str = include_str!("../../../contracts/releases/package_tiers.toml");
#[test]
@@ -145,12 +146,42 @@ fn trade_model_reducer_and_evidence_have_final_public_owners() {
"pub claim_mutation_id:",
] {
assert!(
- !TRADE_CONTRACT.contains(declaration),
+ !REDUCER_IMPLEMENTATION.contains(declaration),
"native trade contract field must remain private: {declaration}"
);
}
}
+#[test]
+fn workflow_plan_is_root_exported_and_side_effect_free() {
+ let _: Option<radroots_trade::WorkflowPlan> = None;
+ let _: Option<radroots_trade::Error> = None;
+
+ for forbidden in [
+ "std::fs",
+ "std::net",
+ "sqlx",
+ "tokio",
+ "reqwest",
+ "event_store",
+ "outbox",
+ ".sign(",
+ ".deliver(",
+ ".execute(",
+ ] {
+ assert!(
+ !WORKFLOW.contains(forbidden),
+ "workflow plan acquired host side-effect authority: {forbidden}"
+ );
+ }
+ for action in ["Sign", "Persist", "Deliver", "VerifyPrivateTerms"] {
+ assert!(
+ WORKFLOW.contains(action),
+ "missing workflow action {action}"
+ );
+ }
+}
+
fn table_keys<'a>(manifest: &'a str, heading: &str) -> BTreeSet<&'a str> {
let Some((_, table)) = manifest.split_once(heading) else {
return BTreeSet::new();