lib

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

reducer_impl.rs (98385B)


      1 #![forbid(unsafe_code)]
      2 //! Versioned reducer contract implementation behind the curated public modules.
      3 
      4 #[cfg(all(not(feature = "std"), feature = "json"))]
      5 use alloc::format;
      6 #[cfg(not(feature = "std"))]
      7 use alloc::{
      8     collections::{BTreeMap, BTreeSet},
      9     string::{String, ToString},
     10     vec::Vec,
     11 };
     12 #[cfg(feature = "std")]
     13 use std::{
     14     collections::{BTreeMap, BTreeSet},
     15     string::{String, ToString},
     16     vec::Vec,
     17 };
     18 
     19 use radroots_event::{
     20     id::{CandidateId, DTag, EventId, MutationId, TradeId},
     21     trade::{
     22         RADROOTS_TRADE_SCHEMA_VERSION, SellerReservationAssertionV1, TradeCandidateTermsV1,
     23         TradeDecisionV1, TradeMutationBodyV1, TradeMutationEnvelopeV1,
     24     },
     25 };
     26 use radroots_identity::PublicKey;
     27 #[cfg(feature = "json")]
     28 use sha2::{Digest, Sha256};
     29 
     30 pub const RADROOTS_TRADE_REDUCER_CONTRACT_ID: &str = "radroots.trade.reducer.v1";
     31 pub const RADROOTS_TRADE_REDUCER_VERSION: u16 = 1;
     32 #[cfg(feature = "json")]
     33 const RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN: &[u8] = b"radroots:trade-projection:v1\0";
     34 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     35 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
     36 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
     37 pub enum RadrootsTradeNegotiationStateV1 {
     38     #[default]
     39     None,
     40     Open,
     41     ClosedDeclined,
     42     ClosedExpired,
     43 }
     44 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     45 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
     46 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
     47 pub enum RadrootsTradeAgreementStateV1 {
     48     #[default]
     49     None,
     50     Agreed,
     51     Contested,
     52     Cancelled,
     53 }
     54 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     55 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
     56 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
     57 pub enum RadrootsTradeEvidenceStateV1 {
     58     Complete,
     59     #[default]
     60     Missing,
     61     QueryPartial,
     62     UnsupportedVersion,
     63 }
     64 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     65 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
     66 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
     67 pub enum RadrootsTradeConflictStateV1 {
     68     #[default]
     69     None,
     70     ConcurrentCandidates,
     71     DoubleAcceptance,
     72     DecisionConflict,
     73     CancellationConflict,
     74     InvalidCausalChain,
     75     InventoryAuthorityConflict,
     76 }
     77 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     78 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
     79 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
     80 pub enum RadrootsTradePrivateTermsStateV1 {
     81     #[default]
     82     NotRequired,
     83     AvailableVerified,
     84     Missing,
     85     Undecryptable,
     86     CommitmentMismatch,
     87 }
     88 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     89 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
     90 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
     91 pub enum RadrootsTradeAttestationStateV1 {
     92     #[default]
     93     None,
     94     PresentValid,
     95     PresentInvalid,
     96     Conflicting,
     97 }
     98 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     99 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    100 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
    101 pub enum RadrootsTradeFulfillmentStateV1 {
    102     #[default]
    103     NotStarted,
    104 }
    105 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    106 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    107 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
    108 pub enum RadrootsTradePaymentStateV1 {
    109     #[default]
    110     NotTracked,
    111 }
    112 
    113 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    114 #[derive(Clone, Debug, PartialEq, Eq)]
    115 pub struct RadrootsTradeReductionInputV1 {
    116     trade_id: TradeId,
    117     mutations: Vec<RadrootsTradeMutationRecordV1>,
    118     private_terms: Vec<RadrootsTradePrivateTermsEvidenceV1>,
    119     attestations: Vec<RadrootsTradeAttestationRecordV1>,
    120     evidence_state: RadrootsTradeEvidenceStateV1,
    121     observed_at_unix_s: Option<u64>,
    122 }
    123 
    124 impl RadrootsTradeReductionInputV1 {
    125     pub fn new(trade_id: TradeId) -> Self {
    126         Self {
    127             trade_id,
    128             mutations: Vec::new(),
    129             private_terms: Vec::new(),
    130             attestations: Vec::new(),
    131             evidence_state: RadrootsTradeEvidenceStateV1::Complete,
    132             observed_at_unix_s: None,
    133         }
    134     }
    135 
    136     #[must_use]
    137     pub fn with_mutations(mut self, mutations: Vec<RadrootsTradeMutationRecordV1>) -> Self {
    138         self.mutations = mutations;
    139         self
    140     }
    141 
    142     #[must_use]
    143     pub fn with_private_terms(
    144         mut self,
    145         private_terms: Vec<RadrootsTradePrivateTermsEvidenceV1>,
    146     ) -> Self {
    147         self.private_terms = private_terms;
    148         self
    149     }
    150 
    151     #[must_use]
    152     pub fn with_attestations(
    153         mut self,
    154         attestations: Vec<RadrootsTradeAttestationRecordV1>,
    155     ) -> Self {
    156         self.attestations = attestations;
    157         self
    158     }
    159 
    160     #[must_use]
    161     pub fn with_evidence_state(mut self, evidence_state: RadrootsTradeEvidenceStateV1) -> Self {
    162         self.evidence_state = evidence_state;
    163         self
    164     }
    165 
    166     #[must_use]
    167     pub fn with_observed_at_unix_s(mut self, observed_at_unix_s: Option<u64>) -> Self {
    168         self.observed_at_unix_s = observed_at_unix_s;
    169         self
    170     }
    171 
    172     pub const fn trade_id(&self) -> &TradeId {
    173         &self.trade_id
    174     }
    175 
    176     pub fn mutations(&self) -> &[RadrootsTradeMutationRecordV1] {
    177         &self.mutations
    178     }
    179 
    180     pub fn private_terms(&self) -> &[RadrootsTradePrivateTermsEvidenceV1] {
    181         &self.private_terms
    182     }
    183 
    184     pub fn attestations(&self) -> &[RadrootsTradeAttestationRecordV1] {
    185         &self.attestations
    186     }
    187 
    188     pub const fn evidence_state(&self) -> RadrootsTradeEvidenceStateV1 {
    189         self.evidence_state
    190     }
    191 
    192     pub const fn observed_at_unix_s(&self) -> Option<u64> {
    193         self.observed_at_unix_s
    194     }
    195 }
    196 
    197 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    198 #[derive(Clone, Debug, PartialEq, Eq)]
    199 pub struct RadrootsTradeMutationRecordV1 {
    200     transport_event_id: Option<EventId>,
    201     mutation: TradeMutationEnvelopeV1,
    202 }
    203 
    204 impl RadrootsTradeMutationRecordV1 {
    205     pub const fn new(
    206         transport_event_id: Option<EventId>,
    207         mutation: TradeMutationEnvelopeV1,
    208     ) -> Self {
    209         Self {
    210             transport_event_id,
    211             mutation,
    212         }
    213     }
    214 
    215     pub const fn transport_event_id(&self) -> Option<&EventId> {
    216         self.transport_event_id.as_ref()
    217     }
    218 
    219     pub const fn mutation(&self) -> &TradeMutationEnvelopeV1 {
    220         &self.mutation
    221     }
    222 }
    223 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    224 #[derive(Clone, Debug, PartialEq, Eq)]
    225 pub struct RadrootsTradePrivateTermsEvidenceV1 {
    226     candidate_id: CandidateId,
    227     state: RadrootsTradePrivateTermsStateV1,
    228 }
    229 
    230 impl RadrootsTradePrivateTermsEvidenceV1 {
    231     pub const fn new(candidate_id: CandidateId, state: RadrootsTradePrivateTermsStateV1) -> Self {
    232         Self {
    233             candidate_id,
    234             state,
    235         }
    236     }
    237 
    238     pub const fn candidate_id(&self) -> &CandidateId {
    239         &self.candidate_id
    240     }
    241 
    242     pub const fn state(&self) -> RadrootsTradePrivateTermsStateV1 {
    243         self.state
    244     }
    245 }
    246 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    247 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
    248 pub enum RadrootsTradeAttestationResultV1 {
    249     Valid,
    250     Invalid,
    251 }
    252 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    253 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
    254 pub struct RadrootsTradeAttestationRecordV1 {
    255     event_id: EventId,
    256     claim_mutation_id: MutationId,
    257     result: RadrootsTradeAttestationResultV1,
    258 }
    259 
    260 impl RadrootsTradeAttestationRecordV1 {
    261     pub const fn new(
    262         event_id: EventId,
    263         claim_mutation_id: MutationId,
    264         result: RadrootsTradeAttestationResultV1,
    265     ) -> Self {
    266         Self {
    267             event_id,
    268             claim_mutation_id,
    269             result,
    270         }
    271     }
    272 
    273     pub const fn event_id(&self) -> &EventId {
    274         &self.event_id
    275     }
    276 
    277     pub const fn claim_mutation_id(&self) -> &MutationId {
    278         &self.claim_mutation_id
    279     }
    280 
    281     pub const fn result(&self) -> RadrootsTradeAttestationResultV1 {
    282         self.result
    283     }
    284 }
    285 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    286 #[derive(Clone, Debug, PartialEq, Eq)]
    287 pub struct RadrootsTradeProjectionV1 {
    288     reducer_contract_id: String,
    289     reducer_version: u16,
    290     trade_id: TradeId,
    291     root_mutation_id: Option<MutationId>,
    292     buyer_pubkey: Option<PublicKey>,
    293     seller_pubkey: Option<PublicKey>,
    294     farm_id: Option<DTag>,
    295     negotiation_state: RadrootsTradeNegotiationStateV1,
    296     agreement_state: RadrootsTradeAgreementStateV1,
    297     evidence_state: RadrootsTradeEvidenceStateV1,
    298     conflict_state: RadrootsTradeConflictStateV1,
    299     private_terms_state: RadrootsTradePrivateTermsStateV1,
    300     attestation_state: RadrootsTradeAttestationStateV1,
    301     fulfillment_state: RadrootsTradeFulfillmentStateV1,
    302     payment_state: RadrootsTradePaymentStateV1,
    303     candidate_heads: Vec<MutationId>,
    304     agreement_claims: Vec<RadrootsTradeAgreementClaimV1>,
    305     active_agreement_claim_ids: Vec<MutationId>,
    306     contested_claim_ids: Vec<MutationId>,
    307     cancelled_claim_ids: Vec<MutationId>,
    308     declined_candidate_ids: Vec<CandidateId>,
    309     missing_parent_ids: Vec<MutationId>,
    310     missing_proposal_ids: Vec<MutationId>,
    311     unsupported_mutation_ids: Vec<MutationId>,
    312     issues: Vec<RadrootsTradeReducerIssueV1>,
    313     attestations: Vec<RadrootsTradeAttestationRecordV1>,
    314     projection_digest: String,
    315 }
    316 
    317 impl RadrootsTradeProjectionV1 {
    318     fn empty(trade_id: TradeId) -> Self {
    319         Self {
    320             reducer_contract_id: RADROOTS_TRADE_REDUCER_CONTRACT_ID.to_string(),
    321             reducer_version: RADROOTS_TRADE_REDUCER_VERSION,
    322             trade_id,
    323             root_mutation_id: None,
    324             buyer_pubkey: None,
    325             seller_pubkey: None,
    326             farm_id: None,
    327             negotiation_state: RadrootsTradeNegotiationStateV1::None,
    328             agreement_state: RadrootsTradeAgreementStateV1::None,
    329             evidence_state: RadrootsTradeEvidenceStateV1::Missing,
    330             conflict_state: RadrootsTradeConflictStateV1::None,
    331             private_terms_state: RadrootsTradePrivateTermsStateV1::NotRequired,
    332             attestation_state: RadrootsTradeAttestationStateV1::None,
    333             fulfillment_state: RadrootsTradeFulfillmentStateV1::NotStarted,
    334             payment_state: RadrootsTradePaymentStateV1::NotTracked,
    335             candidate_heads: Vec::new(),
    336             agreement_claims: Vec::new(),
    337             active_agreement_claim_ids: Vec::new(),
    338             contested_claim_ids: Vec::new(),
    339             cancelled_claim_ids: Vec::new(),
    340             declined_candidate_ids: Vec::new(),
    341             missing_parent_ids: Vec::new(),
    342             missing_proposal_ids: Vec::new(),
    343             unsupported_mutation_ids: Vec::new(),
    344             issues: Vec::new(),
    345             attestations: Vec::new(),
    346             projection_digest: String::new(),
    347         }
    348     }
    349 
    350     fn finish(&mut self) {
    351         self.candidate_heads.sort();
    352         self.candidate_heads.dedup();
    353         self.active_agreement_claim_ids.sort();
    354         self.active_agreement_claim_ids.dedup();
    355         self.contested_claim_ids.sort();
    356         self.contested_claim_ids.dedup();
    357         self.cancelled_claim_ids.sort();
    358         self.cancelled_claim_ids.dedup();
    359         self.declined_candidate_ids.sort();
    360         self.declined_candidate_ids.dedup();
    361         self.missing_parent_ids.sort();
    362         self.missing_parent_ids.dedup();
    363         self.missing_proposal_ids.sort();
    364         self.missing_proposal_ids.dedup();
    365         self.unsupported_mutation_ids.sort();
    366         self.unsupported_mutation_ids.dedup();
    367         self.agreement_claims
    368             .sort_by_key(|left| left.claim_mutation_id);
    369         self.issues.sort();
    370         self.issues.dedup();
    371         self.attestations.sort();
    372         self.attestations.dedup();
    373         match projection_digest(self) {
    374             Ok(digest) => self.projection_digest = digest,
    375             Err(reason) => {
    376                 self.projection_digest.clear();
    377                 self.issues
    378                     .push(RadrootsTradeReducerIssueV1::ProjectionDigestUnavailable { reason });
    379                 self.issues.sort();
    380                 self.issues.dedup();
    381             }
    382         }
    383     }
    384 
    385     pub fn reducer_contract_id(&self) -> &str {
    386         &self.reducer_contract_id
    387     }
    388 
    389     pub const fn reducer_version(&self) -> u16 {
    390         self.reducer_version
    391     }
    392 
    393     pub const fn trade_id(&self) -> &TradeId {
    394         &self.trade_id
    395     }
    396 
    397     pub const fn root_mutation_id(&self) -> Option<&MutationId> {
    398         self.root_mutation_id.as_ref()
    399     }
    400 
    401     pub const fn buyer_pubkey(&self) -> Option<&PublicKey> {
    402         self.buyer_pubkey.as_ref()
    403     }
    404 
    405     pub const fn seller_pubkey(&self) -> Option<&PublicKey> {
    406         self.seller_pubkey.as_ref()
    407     }
    408 
    409     pub const fn farm_id(&self) -> Option<&DTag> {
    410         self.farm_id.as_ref()
    411     }
    412 
    413     pub const fn negotiation_state(&self) -> RadrootsTradeNegotiationStateV1 {
    414         self.negotiation_state
    415     }
    416 
    417     pub const fn agreement_state(&self) -> RadrootsTradeAgreementStateV1 {
    418         self.agreement_state
    419     }
    420 
    421     pub const fn evidence_state(&self) -> RadrootsTradeEvidenceStateV1 {
    422         self.evidence_state
    423     }
    424 
    425     pub const fn conflict_state(&self) -> RadrootsTradeConflictStateV1 {
    426         self.conflict_state
    427     }
    428 
    429     pub const fn private_terms_state(&self) -> RadrootsTradePrivateTermsStateV1 {
    430         self.private_terms_state
    431     }
    432 
    433     pub const fn attestation_state(&self) -> RadrootsTradeAttestationStateV1 {
    434         self.attestation_state
    435     }
    436 
    437     pub const fn fulfillment_state(&self) -> RadrootsTradeFulfillmentStateV1 {
    438         self.fulfillment_state
    439     }
    440 
    441     pub const fn payment_state(&self) -> RadrootsTradePaymentStateV1 {
    442         self.payment_state
    443     }
    444 
    445     pub fn candidate_heads(&self) -> &[MutationId] {
    446         &self.candidate_heads
    447     }
    448 
    449     pub fn agreement_claims(&self) -> &[RadrootsTradeAgreementClaimV1] {
    450         &self.agreement_claims
    451     }
    452 
    453     pub fn active_agreement_claim_ids(&self) -> &[MutationId] {
    454         &self.active_agreement_claim_ids
    455     }
    456 
    457     pub fn contested_claim_ids(&self) -> &[MutationId] {
    458         &self.contested_claim_ids
    459     }
    460 
    461     pub fn cancelled_claim_ids(&self) -> &[MutationId] {
    462         &self.cancelled_claim_ids
    463     }
    464 
    465     pub fn declined_candidate_ids(&self) -> &[CandidateId] {
    466         &self.declined_candidate_ids
    467     }
    468 
    469     pub fn missing_parent_ids(&self) -> &[MutationId] {
    470         &self.missing_parent_ids
    471     }
    472 
    473     pub fn missing_proposal_ids(&self) -> &[MutationId] {
    474         &self.missing_proposal_ids
    475     }
    476 
    477     pub fn unsupported_mutation_ids(&self) -> &[MutationId] {
    478         &self.unsupported_mutation_ids
    479     }
    480 
    481     pub fn issues(&self) -> &[RadrootsTradeReducerIssueV1] {
    482         &self.issues
    483     }
    484 
    485     pub fn attestations(&self) -> &[RadrootsTradeAttestationRecordV1] {
    486         &self.attestations
    487     }
    488 
    489     pub fn projection_digest(&self) -> &str {
    490         &self.projection_digest
    491     }
    492 }
    493 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    494 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
    495 pub struct RadrootsTradeAgreementClaimV1 {
    496     claim_mutation_id: MutationId,
    497     proposal_mutation_id: MutationId,
    498     candidate_id: CandidateId,
    499     candidate_author_pubkey: PublicKey,
    500     accepted_by_pubkey: PublicKey,
    501     reservation_commitment: String,
    502 }
    503 
    504 impl RadrootsTradeAgreementClaimV1 {
    505     pub const fn claim_mutation_id(&self) -> &MutationId {
    506         &self.claim_mutation_id
    507     }
    508 
    509     pub const fn proposal_mutation_id(&self) -> &MutationId {
    510         &self.proposal_mutation_id
    511     }
    512 
    513     pub const fn candidate_id(&self) -> &CandidateId {
    514         &self.candidate_id
    515     }
    516 
    517     pub const fn candidate_author_pubkey(&self) -> &PublicKey {
    518         &self.candidate_author_pubkey
    519     }
    520 
    521     pub const fn accepted_by_pubkey(&self) -> &PublicKey {
    522         &self.accepted_by_pubkey
    523     }
    524 
    525     pub fn reservation_commitment(&self) -> &str {
    526         &self.reservation_commitment
    527     }
    528 }
    529 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    530 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
    531 pub enum RadrootsTradeReducerIssueV1 {
    532     MissingRootProposal,
    533     MultipleRootProposals,
    534     MissingMutationId,
    535     TradeIdentityMismatch {
    536         mutation_id: MutationId,
    537     },
    538     UnsupportedSchema {
    539         mutation_id: MutationId,
    540         schema_version: u16,
    541     },
    542     InvalidMutation {
    543         mutation_id: Option<MutationId>,
    544         reason: String,
    545     },
    546     MissingParent {
    547         mutation_id: MutationId,
    548         parent_mutation_id: MutationId,
    549     },
    550     MissingProposal {
    551         decision_mutation_id: MutationId,
    552         proposal_mutation_id: MutationId,
    553     },
    554     CandidateIdMismatch {
    555         decision_mutation_id: MutationId,
    556         proposal_mutation_id: MutationId,
    557     },
    558     DecisionAuthorMismatch {
    559         decision_mutation_id: MutationId,
    560     },
    561     DecisionParentMissing {
    562         decision_mutation_id: MutationId,
    563         proposal_mutation_id: MutationId,
    564     },
    565     MissingSellerReservation {
    566         decision_mutation_id: MutationId,
    567     },
    568     ReservationCandidateMismatch {
    569         decision_mutation_id: MutationId,
    570     },
    571     ReservationAuthorityMismatch {
    572         decision_mutation_id: MutationId,
    573     },
    574     ReservationLineMismatch {
    575         decision_mutation_id: MutationId,
    576     },
    577     DecisionConflict {
    578         proposal_mutation_id: MutationId,
    579     },
    580     DoubleAcceptance {
    581         proposal_mutation_id: MutationId,
    582     },
    583     CancellationConflict {
    584         cancellation_mutation_id: MutationId,
    585     },
    586     InvalidCausalChain {
    587         mutation_id: MutationId,
    588     },
    589     PrivateTermsUnavailable {
    590         candidate_id: CandidateId,
    591     },
    592     ProjectionDigestUnavailable {
    593         reason: String,
    594     },
    595 }
    596 
    597 #[derive(Clone, Debug, PartialEq, Eq)]
    598 struct CandidateRecord {
    599     proposal_mutation_id: MutationId,
    600     author_pubkey: PublicKey,
    601     candidate: TradeCandidateTermsV1,
    602 }
    603 
    604 struct DecisionApplication<'a> {
    605     mutation_id: &'a MutationId,
    606     mutation: &'a TradeMutationEnvelopeV1,
    607     proposal_mutation_id: &'a MutationId,
    608     candidate_id: &'a CandidateId,
    609     decision: &'a TradeDecisionV1,
    610 }
    611 
    612 #[derive(Clone, Debug, PartialEq, Eq)]
    613 struct CancellationRecord {
    614     mutation_id: MutationId,
    615     parent_mutation_ids: Vec<MutationId>,
    616     target_candidate_id: Option<CandidateId>,
    617     target_claim_mutation_id: Option<MutationId>,
    618 }
    619 
    620 pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTradeProjectionV1 {
    621     let mut projection = RadrootsTradeProjectionV1::empty(input.trade_id);
    622     let mut mutations = BTreeMap::<MutationId, TradeMutationEnvelopeV1>::new();
    623 
    624     for record in input.mutations {
    625         let mutation_id = match record.mutation.mutation_id {
    626             Some(mutation_id) => mutation_id,
    627             None => {
    628                 projection
    629                     .issues
    630                     .push(RadrootsTradeReducerIssueV1::MissingMutationId);
    631                 continue;
    632             }
    633         };
    634         if record.mutation.schema_version != RADROOTS_TRADE_SCHEMA_VERSION {
    635             projection.unsupported_mutation_ids.push(mutation_id);
    636             projection
    637                 .issues
    638                 .push(RadrootsTradeReducerIssueV1::UnsupportedSchema {
    639                     mutation_id,
    640                     schema_version: record.mutation.schema_version,
    641                 });
    642             continue;
    643         }
    644         if let Err(error) = record.mutation.validate() {
    645             projection
    646                 .issues
    647                 .push(RadrootsTradeReducerIssueV1::InvalidMutation {
    648                     mutation_id: Some(mutation_id),
    649                     reason: error.to_string(),
    650                 });
    651             continue;
    652         }
    653         if record.mutation.trade_id != input.trade_id {
    654             projection
    655                 .issues
    656                 .push(RadrootsTradeReducerIssueV1::TradeIdentityMismatch { mutation_id });
    657             continue;
    658         }
    659         mutations.entry(mutation_id).or_insert(record.mutation);
    660     }
    661 
    662     let mut root_proposals = Vec::<MutationId>::new();
    663     let mut candidates_by_proposal = BTreeMap::<MutationId, CandidateRecord>::new();
    664     let mut claims = BTreeMap::<MutationId, RadrootsTradeAgreementClaimV1>::new();
    665     let mut decisions_by_proposal = BTreeMap::<MutationId, Vec<MutationId>>::new();
    666     let mut cancellations = Vec::<CancellationRecord>::new();
    667     let mut referenced_parents = BTreeSet::<MutationId>::new();
    668 
    669     for (mutation_id, mutation) in &mutations {
    670         if matches!(mutation.body, TradeMutationBodyV1::Proposal { .. }) {
    671             root_proposals.push(*mutation_id);
    672         }
    673         for parent in &mutation.parent_mutation_ids {
    674             referenced_parents.insert(*parent);
    675             if !mutations.contains_key(parent) {
    676                 projection.missing_parent_ids.push(*parent);
    677                 projection
    678                     .issues
    679                     .push(RadrootsTradeReducerIssueV1::MissingParent {
    680                         mutation_id: *mutation_id,
    681                         parent_mutation_id: *parent,
    682                     });
    683             }
    684         }
    685         match &mutation.body {
    686             TradeMutationBodyV1::Proposal { candidate }
    687             | TradeMutationBodyV1::RevisionProposal { candidate } => {
    688                 candidates_by_proposal.insert(
    689                     *mutation_id,
    690                     CandidateRecord {
    691                         proposal_mutation_id: *mutation_id,
    692                         author_pubkey: mutation.author_pubkey,
    693                         candidate: candidate.clone(),
    694                     },
    695                 );
    696             }
    697             TradeMutationBodyV1::Decision { .. } | TradeMutationBodyV1::RevisionDecision { .. } => {
    698             }
    699             TradeMutationBodyV1::Cancellation {
    700                 target_candidate_id,
    701                 target_claim_mutation_id,
    702                 reason: _,
    703             } => cancellations.push(CancellationRecord {
    704                 mutation_id: *mutation_id,
    705                 parent_mutation_ids: mutation.parent_mutation_ids.clone(),
    706                 target_candidate_id: *target_candidate_id,
    707                 target_claim_mutation_id: *target_claim_mutation_id,
    708             }),
    709         }
    710     }
    711 
    712     for (mutation_id, mutation) in &mutations {
    713         match &mutation.body {
    714             TradeMutationBodyV1::Decision {
    715                 proposal_mutation_id,
    716                 candidate_id,
    717                 decision,
    718             }
    719             | TradeMutationBodyV1::RevisionDecision {
    720                 proposal_mutation_id,
    721                 candidate_id,
    722                 decision,
    723             } => {
    724                 decisions_by_proposal
    725                     .entry(*proposal_mutation_id)
    726                     .or_default()
    727                     .push(*mutation_id);
    728                 apply_decision(
    729                     DecisionApplication {
    730                         mutation_id,
    731                         mutation,
    732                         proposal_mutation_id,
    733                         candidate_id,
    734                         decision,
    735                     },
    736                     &candidates_by_proposal,
    737                     &mut claims,
    738                     &mut projection,
    739                 );
    740             }
    741             _ => {}
    742         }
    743     }
    744 
    745     if root_proposals.is_empty() {
    746         projection
    747             .issues
    748             .push(RadrootsTradeReducerIssueV1::MissingRootProposal);
    749     } else if root_proposals.len() > 1 {
    750         projection
    751             .issues
    752             .push(RadrootsTradeReducerIssueV1::MultipleRootProposals);
    753         set_conflict(
    754             &mut projection.conflict_state,
    755             RadrootsTradeConflictStateV1::ConcurrentCandidates,
    756         );
    757     } else {
    758         projection.root_mutation_id = root_proposals.first().cloned();
    759         let root = projection
    760             .root_mutation_id
    761             .as_ref()
    762             .and_then(|root_id| mutations.get(root_id))
    763             .expect("root proposal selected from the validated mutation map");
    764         projection.buyer_pubkey = Some(root.buyer_pubkey);
    765         projection.seller_pubkey = Some(root.seller_pubkey);
    766         projection.farm_id = Some(root.farm_id.clone());
    767     }
    768 
    769     for (proposal_mutation_id, decision_ids) in &decisions_by_proposal {
    770         let unique: BTreeSet<MutationId> = decision_ids.iter().cloned().collect();
    771         if unique.len() > 1 {
    772             let accept_count = unique
    773                 .iter()
    774                 .filter(|decision_id| {
    775                     mutations
    776                         .get(*decision_id)
    777                         .is_some_and(|mutation| is_acceptance(&mutation.body))
    778                 })
    779                 .count();
    780             if accept_count > 1 {
    781                 projection
    782                     .issues
    783                     .push(RadrootsTradeReducerIssueV1::DoubleAcceptance {
    784                         proposal_mutation_id: *proposal_mutation_id,
    785                     });
    786                 set_conflict(
    787                     &mut projection.conflict_state,
    788                     RadrootsTradeConflictStateV1::DoubleAcceptance,
    789                 );
    790             }
    791             if accept_count > 0 && accept_count < unique.len() {
    792                 projection
    793                     .issues
    794                     .push(RadrootsTradeReducerIssueV1::DecisionConflict {
    795                         proposal_mutation_id: *proposal_mutation_id,
    796                     });
    797                 set_conflict(
    798                     &mut projection.conflict_state,
    799                     RadrootsTradeConflictStateV1::DecisionConflict,
    800                 );
    801             }
    802         }
    803     }
    804 
    805     projection.candidate_heads = mutations
    806         .keys()
    807         .filter(|mutation_id| !referenced_parents.contains(*mutation_id))
    808         .cloned()
    809         .collect();
    810     projection.declined_candidate_ids = declined_candidate_ids(&mutations);
    811     projection.agreement_claims = claims.values().cloned().collect();
    812     apply_agreement_state(
    813         &mut projection,
    814         &claims,
    815         &mutations,
    816         &candidates_by_proposal,
    817         &cancellations,
    818     );
    819     apply_negotiation_state(
    820         &mut projection,
    821         &candidates_by_proposal,
    822         &claims,
    823         input.observed_at_unix_s,
    824     );
    825     projection.private_terms_state =
    826         reduce_private_terms_state(&projection, &candidates_by_proposal, &input.private_terms);
    827     if matches!(
    828         projection.private_terms_state,
    829         RadrootsTradePrivateTermsStateV1::Missing
    830             | RadrootsTradePrivateTermsStateV1::Undecryptable
    831             | RadrootsTradePrivateTermsStateV1::CommitmentMismatch
    832     ) {
    833         for claim_id in &projection.active_agreement_claim_ids {
    834             let claim = claims
    835                 .get(claim_id)
    836                 .expect("active agreement identifiers originate from indexed claims");
    837             projection
    838                 .issues
    839                 .push(RadrootsTradeReducerIssueV1::PrivateTermsUnavailable {
    840                     candidate_id: claim.candidate_id,
    841                 });
    842         }
    843     }
    844     projection.attestations = input.attestations;
    845     projection.attestation_state = reduce_attestation_state(&projection.attestations);
    846     projection.evidence_state = reduce_evidence_state(&projection, input.evidence_state);
    847     projection.finish();
    848     projection
    849 }
    850 
    851 fn apply_decision(
    852     application: DecisionApplication<'_>,
    853     candidates_by_proposal: &BTreeMap<MutationId, CandidateRecord>,
    854     claims: &mut BTreeMap<MutationId, RadrootsTradeAgreementClaimV1>,
    855     projection: &mut RadrootsTradeProjectionV1,
    856 ) {
    857     let DecisionApplication {
    858         mutation_id,
    859         mutation,
    860         proposal_mutation_id,
    861         candidate_id,
    862         decision,
    863     } = application;
    864     let Some(candidate_record) = candidates_by_proposal.get(proposal_mutation_id) else {
    865         projection.missing_proposal_ids.push(*proposal_mutation_id);
    866         projection
    867             .issues
    868             .push(RadrootsTradeReducerIssueV1::MissingProposal {
    869                 decision_mutation_id: *mutation_id,
    870                 proposal_mutation_id: *proposal_mutation_id,
    871             });
    872         return;
    873     };
    874     if candidate_record.candidate.candidate_id.as_ref() != Some(candidate_id) {
    875         projection
    876             .issues
    877             .push(RadrootsTradeReducerIssueV1::CandidateIdMismatch {
    878                 decision_mutation_id: *mutation_id,
    879                 proposal_mutation_id: *proposal_mutation_id,
    880             });
    881         set_conflict(
    882             &mut projection.conflict_state,
    883             RadrootsTradeConflictStateV1::DecisionConflict,
    884         );
    885         return;
    886     }
    887     if mutation.author_pubkey != candidate_record_author_counterparty(candidate_record, mutation) {
    888         projection
    889             .issues
    890             .push(RadrootsTradeReducerIssueV1::DecisionAuthorMismatch {
    891                 decision_mutation_id: *mutation_id,
    892             });
    893         return;
    894     }
    895     if !mutation.parent_mutation_ids.contains(proposal_mutation_id) {
    896         projection
    897             .issues
    898             .push(RadrootsTradeReducerIssueV1::DecisionParentMissing {
    899                 decision_mutation_id: *mutation_id,
    900                 proposal_mutation_id: *proposal_mutation_id,
    901             });
    902         set_conflict(
    903             &mut projection.conflict_state,
    904             RadrootsTradeConflictStateV1::InvalidCausalChain,
    905         );
    906     }
    907     match decision {
    908         TradeDecisionV1::Accepted {
    909             reservation_assertion,
    910         } => {
    911             let Some(reservation) = reservation_assertion else {
    912                 projection
    913                     .issues
    914                     .push(RadrootsTradeReducerIssueV1::MissingSellerReservation {
    915                         decision_mutation_id: *mutation_id,
    916                     });
    917                 return;
    918             };
    919             if validate_reservation(
    920                 mutation_id,
    921                 candidate_id,
    922                 &candidate_record.candidate,
    923                 reservation,
    924                 projection,
    925             ) {
    926                 claims.insert(
    927                     *mutation_id,
    928                     RadrootsTradeAgreementClaimV1 {
    929                         claim_mutation_id: *mutation_id,
    930                         proposal_mutation_id: candidate_record.proposal_mutation_id,
    931                         candidate_id: *candidate_id,
    932                         candidate_author_pubkey: candidate_record.author_pubkey,
    933                         accepted_by_pubkey: mutation.author_pubkey,
    934                         reservation_commitment: reservation.assertion_commitment.clone(),
    935                     },
    936                 );
    937             }
    938         }
    939         TradeDecisionV1::Declined { .. } => {}
    940     }
    941 }
    942 
    943 fn candidate_record_author_counterparty(
    944     candidate_record: &CandidateRecord,
    945     mutation: &TradeMutationEnvelopeV1,
    946 ) -> PublicKey {
    947     if candidate_record.author_pubkey == mutation.buyer_pubkey {
    948         mutation.seller_pubkey
    949     } else {
    950         mutation.buyer_pubkey
    951     }
    952 }
    953 
    954 fn validate_reservation(
    955     decision_mutation_id: &MutationId,
    956     candidate_id: &CandidateId,
    957     candidate: &TradeCandidateTermsV1,
    958     reservation: &SellerReservationAssertionV1,
    959     projection: &mut RadrootsTradeProjectionV1,
    960 ) -> bool {
    961     let mut valid = true;
    962     if &reservation.candidate_id != candidate_id {
    963         projection
    964             .issues
    965             .push(RadrootsTradeReducerIssueV1::ReservationCandidateMismatch {
    966                 decision_mutation_id: *decision_mutation_id,
    967             });
    968         valid = false;
    969     }
    970     if reservation.inventory_authority_id != candidate.seller_pubkey {
    971         projection
    972             .issues
    973             .push(RadrootsTradeReducerIssueV1::ReservationAuthorityMismatch {
    974                 decision_mutation_id: *decision_mutation_id,
    975             });
    976         set_conflict(
    977             &mut projection.conflict_state,
    978             RadrootsTradeConflictStateV1::InventoryAuthorityConflict,
    979         );
    980         valid = false;
    981     }
    982     if reservation.commitments.len() != candidate.lines.len() {
    983         projection
    984             .issues
    985             .push(RadrootsTradeReducerIssueV1::ReservationLineMismatch {
    986                 decision_mutation_id: *decision_mutation_id,
    987             });
    988         return false;
    989     }
    990     for (line, commitment) in candidate.lines.iter().zip(reservation.commitments.iter()) {
    991         if line.line_id != commitment.line_id
    992             || line.bin_id != commitment.bin_id
    993             || line.quantity_mantissa != commitment.quantity_mantissa
    994             || line.quantity_scale != commitment.quantity_scale
    995             || line.unit_code != commitment.unit_code
    996         {
    997             projection
    998                 .issues
    999                 .push(RadrootsTradeReducerIssueV1::ReservationLineMismatch {
   1000                     decision_mutation_id: *decision_mutation_id,
   1001                 });
   1002             valid = false;
   1003             break;
   1004         }
   1005     }
   1006     valid
   1007 }
   1008 
   1009 fn apply_agreement_state(
   1010     projection: &mut RadrootsTradeProjectionV1,
   1011     claims: &BTreeMap<MutationId, RadrootsTradeAgreementClaimV1>,
   1012     mutations: &BTreeMap<MutationId, TradeMutationEnvelopeV1>,
   1013     candidates_by_proposal: &BTreeMap<MutationId, CandidateRecord>,
   1014     cancellations: &[CancellationRecord],
   1015 ) {
   1016     if claims.is_empty() {
   1017         if cancellation_without_claim(cancellations, candidates_by_proposal) {
   1018             projection.agreement_state = RadrootsTradeAgreementStateV1::Cancelled;
   1019         }
   1020         return;
   1021     }
   1022 
   1023     let active_claim_ids = non_dominated_claim_ids(claims, mutations);
   1024     let active_claims = active_claim_ids
   1025         .iter()
   1026         .filter_map(|claim_id| claims.get(claim_id))
   1027         .collect::<Vec<_>>();
   1028     let compatible = compatible_claims(&active_claims);
   1029     projection.active_agreement_claim_ids = active_claim_ids;
   1030 
   1031     if compatible {
   1032         projection.agreement_state = RadrootsTradeAgreementStateV1::Agreed;
   1033     } else {
   1034         projection.agreement_state = RadrootsTradeAgreementStateV1::Contested;
   1035         projection.contested_claim_ids = projection.active_agreement_claim_ids.clone();
   1036         if projection.conflict_state == RadrootsTradeConflictStateV1::None {
   1037             set_conflict(
   1038                 &mut projection.conflict_state,
   1039                 RadrootsTradeConflictStateV1::DecisionConflict,
   1040             );
   1041         }
   1042     }
   1043 
   1044     for cancellation in cancellations {
   1045         if let Some(target_claim_id) = &cancellation.target_claim_mutation_id
   1046             && claims.contains_key(target_claim_id)
   1047         {
   1048             if cancellation.parent_mutation_ids.contains(target_claim_id) {
   1049                 projection.cancelled_claim_ids.push(*target_claim_id);
   1050                 if projection.active_agreement_claim_ids == [*target_claim_id] {
   1051                     projection.agreement_state = RadrootsTradeAgreementStateV1::Cancelled;
   1052                 }
   1053             } else {
   1054                 projection
   1055                     .issues
   1056                     .push(RadrootsTradeReducerIssueV1::CancellationConflict {
   1057                         cancellation_mutation_id: cancellation.mutation_id,
   1058                     });
   1059                 projection.agreement_state = RadrootsTradeAgreementStateV1::Contested;
   1060                 set_conflict(
   1061                     &mut projection.conflict_state,
   1062                     RadrootsTradeConflictStateV1::CancellationConflict,
   1063                 );
   1064             }
   1065         }
   1066     }
   1067 }
   1068 
   1069 fn cancellation_without_claim(
   1070     cancellations: &[CancellationRecord],
   1071     candidates_by_proposal: &BTreeMap<MutationId, CandidateRecord>,
   1072 ) -> bool {
   1073     cancellations.iter().any(|cancellation| {
   1074         cancellation
   1075             .target_candidate_id
   1076             .as_ref()
   1077             .is_some_and(|candidate_id| {
   1078                 candidates_by_proposal.values().any(|candidate| {
   1079                     candidate.candidate.candidate_id.as_ref() == Some(candidate_id)
   1080                         && candidate.candidate.cancellation.buyer_pre_agreement
   1081                 })
   1082             })
   1083     })
   1084 }
   1085 
   1086 fn non_dominated_claim_ids(
   1087     claims: &BTreeMap<MutationId, RadrootsTradeAgreementClaimV1>,
   1088     mutations: &BTreeMap<MutationId, TradeMutationEnvelopeV1>,
   1089 ) -> Vec<MutationId> {
   1090     let mut memo = BTreeMap::<MutationId, BTreeSet<MutationId>>::new();
   1091     claims
   1092         .keys()
   1093         .filter(|claim_id| {
   1094             !claims.keys().any(|other_claim_id| {
   1095                 other_claim_id != *claim_id
   1096                     && ancestors_of(other_claim_id, mutations, &mut memo).contains(*claim_id)
   1097             })
   1098         })
   1099         .cloned()
   1100         .collect()
   1101 }
   1102 
   1103 fn compatible_claims(claims: &[&RadrootsTradeAgreementClaimV1]) -> bool {
   1104     let first = claims
   1105         .first()
   1106         .expect("non-empty claims produce at least one non-dominated claim");
   1107     claims.iter().all(|claim| {
   1108         claim.candidate_id == first.candidate_id
   1109             && claim.reservation_commitment == first.reservation_commitment
   1110     })
   1111 }
   1112 
   1113 fn ancestors_of(
   1114     mutation_id: &MutationId,
   1115     mutations: &BTreeMap<MutationId, TradeMutationEnvelopeV1>,
   1116     memo: &mut BTreeMap<MutationId, BTreeSet<MutationId>>,
   1117 ) -> BTreeSet<MutationId> {
   1118     if let Some(cached) = memo.get(mutation_id) {
   1119         return cached.clone();
   1120     }
   1121     let mut ancestors = BTreeSet::new();
   1122     if let Some(mutation) = mutations.get(mutation_id) {
   1123         for parent in &mutation.parent_mutation_ids {
   1124             ancestors.insert(*parent);
   1125             ancestors.extend(ancestors_of(parent, mutations, memo));
   1126         }
   1127     }
   1128     memo.insert(*mutation_id, ancestors.clone());
   1129     ancestors
   1130 }
   1131 
   1132 fn apply_negotiation_state(
   1133     projection: &mut RadrootsTradeProjectionV1,
   1134     candidates_by_proposal: &BTreeMap<MutationId, CandidateRecord>,
   1135     claims: &BTreeMap<MutationId, RadrootsTradeAgreementClaimV1>,
   1136     observed_at_unix_s: Option<u64>,
   1137 ) {
   1138     if candidates_by_proposal.is_empty() {
   1139         projection.negotiation_state = RadrootsTradeNegotiationStateV1::None;
   1140     } else if claims.is_empty()
   1141         && observed_at_unix_s.is_some_and(|observed_at| {
   1142             candidates_by_proposal
   1143                 .values()
   1144                 .all(|candidate| candidate.candidate.proposal_expires_at_unix_s <= observed_at)
   1145         })
   1146     {
   1147         projection.negotiation_state = RadrootsTradeNegotiationStateV1::ClosedExpired;
   1148     } else if claims.is_empty() && !projection.declined_candidate_ids.is_empty() {
   1149         projection.negotiation_state = RadrootsTradeNegotiationStateV1::ClosedDeclined;
   1150     } else {
   1151         projection.negotiation_state = RadrootsTradeNegotiationStateV1::Open;
   1152     }
   1153 }
   1154 
   1155 fn reduce_private_terms_state(
   1156     projection: &RadrootsTradeProjectionV1,
   1157     candidates_by_proposal: &BTreeMap<MutationId, CandidateRecord>,
   1158     private_terms: &[RadrootsTradePrivateTermsEvidenceV1],
   1159 ) -> RadrootsTradePrivateTermsStateV1 {
   1160     let mut private_terms_by_candidate =
   1161         BTreeMap::<CandidateId, RadrootsTradePrivateTermsStateV1>::new();
   1162     for record in private_terms {
   1163         private_terms_by_candidate
   1164             .entry(record.candidate_id)
   1165             .and_modify(|state| *state = (*state).max(record.state))
   1166             .or_insert(record.state);
   1167     }
   1168     let mut required_states = Vec::new();
   1169     for claim in &projection.agreement_claims {
   1170         let candidate_record = candidates_by_proposal
   1171             .get(&claim.proposal_mutation_id)
   1172             .expect("agreement claims originate from indexed candidates");
   1173         let requires_private_terms = candidate_record.candidate.private_terms.is_some()
   1174             || candidate_record
   1175                 .candidate
   1176                 .fulfillment
   1177                 .requires_private_terms;
   1178         if requires_private_terms {
   1179             required_states.push(
   1180                 private_terms_by_candidate
   1181                     .get(&claim.candidate_id)
   1182                     .copied()
   1183                     .unwrap_or(RadrootsTradePrivateTermsStateV1::Missing),
   1184             );
   1185         }
   1186     }
   1187     if required_states.is_empty() {
   1188         RadrootsTradePrivateTermsStateV1::NotRequired
   1189     } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::CommitmentMismatch) {
   1190         RadrootsTradePrivateTermsStateV1::CommitmentMismatch
   1191     } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::Undecryptable) {
   1192         RadrootsTradePrivateTermsStateV1::Undecryptable
   1193     } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::Missing) {
   1194         RadrootsTradePrivateTermsStateV1::Missing
   1195     } else {
   1196         RadrootsTradePrivateTermsStateV1::AvailableVerified
   1197     }
   1198 }
   1199 
   1200 fn reduce_attestation_state(
   1201     attestations: &[RadrootsTradeAttestationRecordV1],
   1202 ) -> RadrootsTradeAttestationStateV1 {
   1203     let mut has_valid = false;
   1204     let mut has_invalid = false;
   1205     for attestation in attestations {
   1206         match attestation.result {
   1207             RadrootsTradeAttestationResultV1::Valid => has_valid = true,
   1208             RadrootsTradeAttestationResultV1::Invalid => has_invalid = true,
   1209         }
   1210     }
   1211     match (has_valid, has_invalid) {
   1212         (false, false) => RadrootsTradeAttestationStateV1::None,
   1213         (true, false) => RadrootsTradeAttestationStateV1::PresentValid,
   1214         (false, true) => RadrootsTradeAttestationStateV1::PresentInvalid,
   1215         (true, true) => RadrootsTradeAttestationStateV1::Conflicting,
   1216     }
   1217 }
   1218 
   1219 fn reduce_evidence_state(
   1220     projection: &RadrootsTradeProjectionV1,
   1221     requested_state: RadrootsTradeEvidenceStateV1,
   1222 ) -> RadrootsTradeEvidenceStateV1 {
   1223     if !projection.unsupported_mutation_ids.is_empty() {
   1224         RadrootsTradeEvidenceStateV1::UnsupportedVersion
   1225     } else if !projection.missing_parent_ids.is_empty()
   1226         || !projection.missing_proposal_ids.is_empty()
   1227         || projection.root_mutation_id.is_none()
   1228     {
   1229         RadrootsTradeEvidenceStateV1::Missing
   1230     } else {
   1231         requested_state
   1232     }
   1233 }
   1234 
   1235 fn declined_candidate_ids(
   1236     mutations: &BTreeMap<MutationId, TradeMutationEnvelopeV1>,
   1237 ) -> Vec<CandidateId> {
   1238     mutations
   1239         .values()
   1240         .filter_map(|mutation| match &mutation.body {
   1241             TradeMutationBodyV1::Decision {
   1242                 candidate_id,
   1243                 decision: TradeDecisionV1::Declined { .. },
   1244                 ..
   1245             }
   1246             | TradeMutationBodyV1::RevisionDecision {
   1247                 candidate_id,
   1248                 decision: TradeDecisionV1::Declined { .. },
   1249                 ..
   1250             } => Some(*candidate_id),
   1251             _ => None,
   1252         })
   1253         .collect()
   1254 }
   1255 
   1256 fn is_acceptance(body: &TradeMutationBodyV1) -> bool {
   1257     matches!(
   1258         body,
   1259         TradeMutationBodyV1::Decision {
   1260             decision: TradeDecisionV1::Accepted { .. },
   1261             ..
   1262         } | TradeMutationBodyV1::RevisionDecision {
   1263             decision: TradeDecisionV1::Accepted { .. },
   1264             ..
   1265         }
   1266     )
   1267 }
   1268 
   1269 fn set_conflict(
   1270     current: &mut RadrootsTradeConflictStateV1,
   1271     candidate: RadrootsTradeConflictStateV1,
   1272 ) {
   1273     if *current == RadrootsTradeConflictStateV1::None || candidate > *current {
   1274         *current = candidate;
   1275     }
   1276 }
   1277 
   1278 #[cfg(feature = "json")]
   1279 fn projection_digest(projection: &RadrootsTradeProjectionV1) -> Result<String, String> {
   1280     let mut digest_input = projection.clone();
   1281     digest_input.projection_digest.clear();
   1282     let value = serde_json::to_value(&digest_input)
   1283         .map_err(|error| format!("projection serialization failed: {error}"))?;
   1284     let canonical = radroots_event::trade::canonical_jcs_value(&value)
   1285         .map_err(|error| format!("projection canonicalization failed: {error}"))?;
   1286     let mut hasher = Sha256::new();
   1287     hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN);
   1288     hasher.update(canonical.as_bytes());
   1289     Ok(hex::encode(hasher.finalize()))
   1290 }
   1291 
   1292 #[cfg(not(feature = "json"))]
   1293 fn projection_digest(_projection: &RadrootsTradeProjectionV1) -> Result<String, String> {
   1294     Err("projection digest requires the json feature".to_string())
   1295 }
   1296 
   1297 #[cfg(test)]
   1298 #[cfg_attr(coverage_nightly, coverage(off))]
   1299 mod tests {
   1300     use super::*;
   1301     use crate::test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_BOB_PUBLIC_KEY_HEX};
   1302     use radroots_event::{
   1303         id::{ClassifiedListingAddress, InventoryBinId},
   1304         trade::{
   1305             FulfillmentProfileV1, RADROOTS_TRADE_DECISION_CONTRACT_ID,
   1306             RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID,
   1307             RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, SellerReservationLineV1,
   1308             TradeCancellationProfileV1, TradeCandidateLineV1, TradeEconomicAdjustmentV1,
   1309             TradeEconomicsProfileV1, TradeLineTombstoneV1, TradePrivateTermsRefV1,
   1310             canonical_trade_mutation_content,
   1311         },
   1312     };
   1313 
   1314     const CANONICAL_REDUCER_VECTORS: &str =
   1315         include_str!("../../../contracts/conformance/vectors/trade/reduce_records.v1.json");
   1316     const PACKAGED_REDUCER_VECTORS: &str = include_str!("../tests/fixtures/reduce_records.v1.json");
   1317 
   1318     fn hex_64(character: char) -> String {
   1319         core::iter::repeat_n(character, 64).collect()
   1320     }
   1321 
   1322     fn hex_32(character: char) -> String {
   1323         core::iter::repeat_n(character, 32).collect()
   1324     }
   1325 
   1326     fn pubkey(character: char) -> PublicKey {
   1327         let public_key_hex = match character {
   1328             'a' => FIXTURE_ALICE_PUBLIC_KEY_HEX,
   1329             'b' => FIXTURE_BOB_PUBLIC_KEY_HEX,
   1330             _ => panic!("unsupported fixture public key label: {character}"),
   1331         };
   1332         PublicKey::from_hex(public_key_hex).expect("fixture pubkey")
   1333     }
   1334 
   1335     fn event_id(character: char) -> EventId {
   1336         EventId::parse(hex_64(character)).unwrap()
   1337     }
   1338 
   1339     fn trade_id() -> TradeId {
   1340         TradeId::parse(hex_32('1')).unwrap()
   1341     }
   1342 
   1343     fn dtag(value: &str) -> DTag {
   1344         DTag::parse(value).unwrap()
   1345     }
   1346 
   1347     fn bin_id(value: &str) -> InventoryBinId {
   1348         InventoryBinId::parse(value).unwrap()
   1349     }
   1350 
   1351     fn candidate(line_suffix: &str) -> TradeCandidateTermsV1 {
   1352         TradeCandidateTermsV1 {
   1353             candidate_id: None,
   1354             schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
   1355             base_candidate_id: None,
   1356             supersession_intent: None,
   1357             buyer_pubkey: pubkey('a'),
   1358             seller_pubkey: pubkey('b'),
   1359             farm_id: dtag("farm-1"),
   1360             lines: vec![TradeCandidateLineV1 {
   1361                 line_id: dtag(&format!("line-{line_suffix}")),
   1362                 listing_addr: ClassifiedListingAddress::parse(format!(
   1363                     "30402:{}:listing-{line_suffix}",
   1364                     FIXTURE_BOB_PUBLIC_KEY_HEX
   1365                 ))
   1366                 .unwrap(),
   1367                 listing_event_id: event_id('c'),
   1368                 listing_snapshot_sha256: hex_64('d'),
   1369                 product_id: format!("carrots-{line_suffix}"),
   1370                 option_id: None,
   1371                 bin_id: bin_id(&format!("bin-{line_suffix}")),
   1372                 quantity_mantissa: "2".to_string(),
   1373                 quantity_scale: 0,
   1374                 unit_code: "count".to_string(),
   1375                 unit_profile: "mvp-count".to_string(),
   1376                 unit_price_mantissa: "500".to_string(),
   1377                 currency_code: "USD".to_string(),
   1378                 line_subtotal_mantissa: "1000".to_string(),
   1379                 replaces_line_id: None,
   1380             }],
   1381             line_tombstones: Vec::<TradeLineTombstoneV1>::new(),
   1382             economics: TradeEconomicsProfileV1 {
   1383                 profile_id: "mvp-fixed".to_string(),
   1384                 currency_code: "USD".to_string(),
   1385                 currency_exponent: 2,
   1386                 rounding_profile: "half-even".to_string(),
   1387                 subtotal_mantissa: "1000".to_string(),
   1388                 discount_total_mantissa: "0".to_string(),
   1389                 adjustment_total_mantissa: "0".to_string(),
   1390                 total_mantissa: "1000".to_string(),
   1391                 adjustments: Vec::<TradeEconomicAdjustmentV1>::new(),
   1392             },
   1393             fulfillment: FulfillmentProfileV1 {
   1394                 profile_id: "market-pickup".to_string(),
   1395                 method: "pickup".to_string(),
   1396                 starts_at_unix_s: 1_800_000_000,
   1397                 ends_at_unix_s: 1_800_003_600,
   1398                 timezone: "America/New_York".to_string(),
   1399                 utc_offset_seconds: -18_000,
   1400                 fold: 0,
   1401                 location_class: "farmstand".to_string(),
   1402                 requires_private_terms: false,
   1403             },
   1404             cancellation: TradeCancellationProfileV1 {
   1405                 profile_id: "buyer-pre-agreement".to_string(),
   1406                 buyer_pre_agreement: true,
   1407                 post_agreement_cutoff_unix_s: Some(1_799_990_000),
   1408             },
   1409             private_terms: None,
   1410             proposal_expires_at_unix_s: 1_799_999_000,
   1411         }
   1412     }
   1413 
   1414     fn proposal() -> TradeMutationEnvelopeV1 {
   1415         canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
   1416             mutation_id: None,
   1417             contract_id: RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_string(),
   1418             schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
   1419             trade_id: trade_id(),
   1420             root_mutation_id: None,
   1421             buyer_pubkey: pubkey('a'),
   1422             seller_pubkey: pubkey('b'),
   1423             farm_id: dtag("farm-1"),
   1424             parent_mutation_ids: Vec::new(),
   1425             author_pubkey: pubkey('a'),
   1426             counterparty_pubkey: pubkey('b'),
   1427             authored_at_unix_s: 100,
   1428             body: TradeMutationBodyV1::Proposal {
   1429                 candidate: candidate("1"),
   1430             },
   1431         })
   1432         .unwrap()
   1433         .envelope
   1434     }
   1435 
   1436     fn reservation(
   1437         candidate: &TradeCandidateTermsV1,
   1438         marker: char,
   1439     ) -> SellerReservationAssertionV1 {
   1440         SellerReservationAssertionV1 {
   1441             reservation_id: dtag(&format!("reservation-{marker}")),
   1442             inventory_authority_id: candidate.seller_pubkey,
   1443             inventory_epoch: 42,
   1444             candidate_id: candidate.candidate_id.unwrap(),
   1445             commitments: candidate
   1446                 .lines
   1447                 .iter()
   1448                 .map(|line| SellerReservationLineV1 {
   1449                     line_id: line.line_id.clone(),
   1450                     bin_id: line.bin_id.clone(),
   1451                     quantity_mantissa: line.quantity_mantissa.clone(),
   1452                     quantity_scale: line.quantity_scale,
   1453                     unit_code: line.unit_code.clone(),
   1454                 })
   1455                 .collect(),
   1456             reservation_expires_at_unix_s: 1_800_000_000,
   1457             assertion_commitment: hex_64(marker),
   1458         }
   1459     }
   1460 
   1461     fn accepted_decision(
   1462         proposal: &TradeMutationEnvelopeV1,
   1463         marker: char,
   1464     ) -> TradeMutationEnvelopeV1 {
   1465         let proposal_id = proposal.mutation_id.unwrap();
   1466         let candidate = match &proposal.body {
   1467             TradeMutationBodyV1::Proposal { candidate }
   1468             | TradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(),
   1469             _ => unreachable!(),
   1470         };
   1471         canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
   1472             mutation_id: None,
   1473             contract_id: RADROOTS_TRADE_DECISION_CONTRACT_ID.to_string(),
   1474             schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
   1475             trade_id: trade_id(),
   1476             root_mutation_id: Some(root_id(proposal)),
   1477             buyer_pubkey: pubkey('a'),
   1478             seller_pubkey: pubkey('b'),
   1479             farm_id: dtag("farm-1"),
   1480             parent_mutation_ids: vec![proposal_id],
   1481             author_pubkey: pubkey('b'),
   1482             counterparty_pubkey: pubkey('a'),
   1483             authored_at_unix_s: u64::from(marker),
   1484             body: TradeMutationBodyV1::Decision {
   1485                 proposal_mutation_id: proposal_id,
   1486                 candidate_id: candidate.candidate_id.unwrap(),
   1487                 decision: TradeDecisionV1::Accepted {
   1488                     reservation_assertion: Some(reservation(&candidate, marker)),
   1489                 },
   1490             },
   1491         })
   1492         .unwrap()
   1493         .envelope
   1494     }
   1495 
   1496     fn declined_decision(proposal: &TradeMutationEnvelopeV1) -> TradeMutationEnvelopeV1 {
   1497         let proposal_id = proposal.mutation_id.unwrap();
   1498         let candidate = match &proposal.body {
   1499             TradeMutationBodyV1::Proposal { candidate }
   1500             | TradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(),
   1501             _ => unreachable!(),
   1502         };
   1503         canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
   1504             mutation_id: None,
   1505             contract_id: RADROOTS_TRADE_DECISION_CONTRACT_ID.to_string(),
   1506             schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
   1507             trade_id: trade_id(),
   1508             root_mutation_id: Some(root_id(proposal)),
   1509             buyer_pubkey: pubkey('a'),
   1510             seller_pubkey: pubkey('b'),
   1511             farm_id: dtag("farm-1"),
   1512             parent_mutation_ids: vec![proposal_id],
   1513             author_pubkey: pubkey('b'),
   1514             counterparty_pubkey: pubkey('a'),
   1515             authored_at_unix_s: 102,
   1516             body: TradeMutationBodyV1::Decision {
   1517                 proposal_mutation_id: proposal_id,
   1518                 candidate_id: candidate.candidate_id.unwrap(),
   1519                 decision: TradeDecisionV1::Declined {
   1520                     reason: "unavailable".to_string(),
   1521                 },
   1522             },
   1523         })
   1524         .unwrap()
   1525         .envelope
   1526     }
   1527 
   1528     fn revision_proposal(
   1529         root: &TradeMutationEnvelopeV1,
   1530         parents: Vec<MutationId>,
   1531     ) -> TradeMutationEnvelopeV1 {
   1532         let mut parents = parents;
   1533         parents.sort();
   1534         canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
   1535             mutation_id: None,
   1536             contract_id: RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID.to_string(),
   1537             schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
   1538             trade_id: trade_id(),
   1539             root_mutation_id: Some(root_id(root)),
   1540             buyer_pubkey: pubkey('a'),
   1541             seller_pubkey: pubkey('b'),
   1542             farm_id: dtag("farm-1"),
   1543             parent_mutation_ids: parents,
   1544             author_pubkey: pubkey('a'),
   1545             counterparty_pubkey: pubkey('b'),
   1546             authored_at_unix_s: 200,
   1547             body: TradeMutationBodyV1::RevisionProposal {
   1548                 candidate: candidate("2"),
   1549             },
   1550         })
   1551         .unwrap()
   1552         .envelope
   1553     }
   1554 
   1555     fn revision_acceptance(
   1556         root: &TradeMutationEnvelopeV1,
   1557         proposal: &TradeMutationEnvelopeV1,
   1558     ) -> TradeMutationEnvelopeV1 {
   1559         let proposal_id = proposal.mutation_id.unwrap();
   1560         let candidate = match &proposal.body {
   1561             TradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(),
   1562             _ => unreachable!(),
   1563         };
   1564         canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
   1565             mutation_id: None,
   1566             contract_id: RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID.to_string(),
   1567             schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
   1568             trade_id: trade_id(),
   1569             root_mutation_id: Some(root_id(root)),
   1570             buyer_pubkey: pubkey('a'),
   1571             seller_pubkey: pubkey('b'),
   1572             farm_id: dtag("farm-1"),
   1573             parent_mutation_ids: vec![proposal_id],
   1574             author_pubkey: pubkey('b'),
   1575             counterparty_pubkey: pubkey('a'),
   1576             authored_at_unix_s: 201,
   1577             body: TradeMutationBodyV1::RevisionDecision {
   1578                 proposal_mutation_id: proposal_id,
   1579                 candidate_id: candidate.candidate_id.unwrap(),
   1580                 decision: TradeDecisionV1::Accepted {
   1581                     reservation_assertion: Some(reservation(&candidate, '9')),
   1582                 },
   1583             },
   1584         })
   1585         .unwrap()
   1586         .envelope
   1587     }
   1588 
   1589     fn cancellation(
   1590         root: &TradeMutationEnvelopeV1,
   1591         target_claim: MutationId,
   1592         parent: MutationId,
   1593     ) -> TradeMutationEnvelopeV1 {
   1594         canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
   1595             mutation_id: None,
   1596             contract_id: radroots_event::trade::RADROOTS_TRADE_CANCELLATION_CONTRACT_ID.to_string(),
   1597             schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
   1598             trade_id: trade_id(),
   1599             root_mutation_id: Some(root_id(root)),
   1600             buyer_pubkey: pubkey('a'),
   1601             seller_pubkey: pubkey('b'),
   1602             farm_id: dtag("farm-1"),
   1603             parent_mutation_ids: vec![parent],
   1604             author_pubkey: pubkey('a'),
   1605             counterparty_pubkey: pubkey('b'),
   1606             authored_at_unix_s: 300,
   1607             body: TradeMutationBodyV1::Cancellation {
   1608                 target_candidate_id: None,
   1609                 target_claim_mutation_id: Some(target_claim),
   1610                 reason: "before cutoff".to_string(),
   1611             },
   1612         })
   1613         .unwrap()
   1614         .envelope
   1615     }
   1616 
   1617     fn root_id(envelope: &TradeMutationEnvelopeV1) -> MutationId {
   1618         envelope.mutation_id.unwrap()
   1619     }
   1620 
   1621     fn record(mutation: TradeMutationEnvelopeV1) -> RadrootsTradeMutationRecordV1 {
   1622         RadrootsTradeMutationRecordV1 {
   1623             transport_event_id: None,
   1624             mutation,
   1625         }
   1626     }
   1627 
   1628     fn reduce(mutations: Vec<TradeMutationEnvelopeV1>) -> RadrootsTradeProjectionV1 {
   1629         let mut input = RadrootsTradeReductionInputV1::new(trade_id());
   1630         input.mutations = mutations.into_iter().map(record).collect();
   1631         reduce_trade_records(input)
   1632     }
   1633 
   1634     fn recanonicalize(mut mutation: TradeMutationEnvelopeV1) -> TradeMutationEnvelopeV1 {
   1635         mutation.mutation_id = None;
   1636         canonical_trade_mutation_content(mutation)
   1637             .expect("recanonicalized mutation")
   1638             .envelope
   1639     }
   1640 
   1641     fn candidate_cancellation(root: &TradeMutationEnvelopeV1) -> TradeMutationEnvelopeV1 {
   1642         let candidate_id = match &root.body {
   1643             TradeMutationBodyV1::Proposal { candidate } => {
   1644                 candidate.candidate_id.expect("candidate id")
   1645             }
   1646             _ => unreachable!(),
   1647         };
   1648         canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
   1649             mutation_id: None,
   1650             contract_id: radroots_event::trade::RADROOTS_TRADE_CANCELLATION_CONTRACT_ID.to_string(),
   1651             schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
   1652             trade_id: trade_id(),
   1653             root_mutation_id: Some(root_id(root)),
   1654             buyer_pubkey: pubkey('a'),
   1655             seller_pubkey: pubkey('b'),
   1656             farm_id: dtag("farm-1"),
   1657             parent_mutation_ids: vec![root_id(root)],
   1658             author_pubkey: pubkey('a'),
   1659             counterparty_pubkey: pubkey('b'),
   1660             authored_at_unix_s: 301,
   1661             body: TradeMutationBodyV1::Cancellation {
   1662                 target_candidate_id: Some(candidate_id),
   1663                 target_claim_mutation_id: None,
   1664                 reason: "cancel before agreement".to_string(),
   1665             },
   1666         })
   1667         .expect("candidate cancellation")
   1668         .envelope
   1669     }
   1670 
   1671     #[test]
   1672     fn reducer_digest_is_independent_of_input_order_and_duplicates() {
   1673         let proposal = proposal();
   1674         let decision = accepted_decision(&proposal, '1');
   1675         let first = reduce(vec![proposal.clone(), decision.clone(), decision.clone()]);
   1676         let second = reduce(vec![decision, proposal]);
   1677 
   1678         assert_eq!(first.agreement_state, RadrootsTradeAgreementStateV1::Agreed);
   1679         assert_eq!(
   1680             first.projection_digest,
   1681             "35a5f555344febe675f4e5e6b15865356400b2b8f7791fc631590e0f1e1fd441"
   1682         );
   1683         assert_eq!(first.projection_digest, second.projection_digest);
   1684         assert_eq!(first.active_agreement_claim_ids.len(), 1);
   1685     }
   1686 
   1687     #[test]
   1688     fn reducer_projection_is_identical_for_every_three_record_permutation() {
   1689         let proposal = proposal();
   1690         let decision = accepted_decision(&proposal, '1');
   1691         let cancellation = cancellation(&proposal, root_id(&decision), root_id(&decision));
   1692         let permutations = [
   1693             vec![proposal.clone(), decision.clone(), cancellation.clone()],
   1694             vec![proposal.clone(), cancellation.clone(), decision.clone()],
   1695             vec![decision.clone(), proposal.clone(), cancellation.clone()],
   1696             vec![decision.clone(), cancellation.clone(), proposal.clone()],
   1697             vec![cancellation.clone(), proposal.clone(), decision.clone()],
   1698             vec![cancellation, decision, proposal],
   1699         ];
   1700         let expected = reduce(permutations[0].clone());
   1701 
   1702         for permutation in permutations.into_iter().skip(1) {
   1703             assert_eq!(reduce(permutation), expected);
   1704         }
   1705     }
   1706 
   1707     #[test]
   1708     fn reducer_excludes_unsupported_versions_from_domain_semantics() {
   1709         let mut unsupported = proposal();
   1710         unsupported.schema_version += 1;
   1711         let unsupported_id = root_id(&unsupported);
   1712 
   1713         let projection = reduce(vec![unsupported]);
   1714 
   1715         assert_eq!(
   1716             projection.evidence_state,
   1717             RadrootsTradeEvidenceStateV1::UnsupportedVersion
   1718         );
   1719         assert_eq!(
   1720             projection.negotiation_state,
   1721             RadrootsTradeNegotiationStateV1::None
   1722         );
   1723         assert_eq!(
   1724             projection.agreement_state,
   1725             RadrootsTradeAgreementStateV1::None
   1726         );
   1727         assert_eq!(projection.root_mutation_id, None);
   1728         assert_eq!(projection.unsupported_mutation_ids, vec![unsupported_id]);
   1729     }
   1730 
   1731     #[test]
   1732     fn reducer_private_evidence_precedence_is_permutation_independent() {
   1733         let mut root = proposal();
   1734         if let TradeMutationBodyV1::Proposal { candidate } = &mut root.body {
   1735             candidate.private_terms = Some(TradePrivateTermsRefV1 {
   1736                 artifact_id: "artifact-1".to_string(),
   1737                 schema_id: "radroots.private.fulfillment.v1".to_string(),
   1738                 ciphertext_commitment: hex_64('f'),
   1739                 required_acknowledgement: true,
   1740             });
   1741         }
   1742         let root = recanonicalize(root);
   1743         let candidate_id = match &root.body {
   1744             TradeMutationBodyV1::Proposal { candidate } => candidate.candidate_id.unwrap(),
   1745             _ => unreachable!(),
   1746         };
   1747         let decision = accepted_decision(&root, '1');
   1748         let evidence = |state| RadrootsTradePrivateTermsEvidenceV1::new(candidate_id, state);
   1749         let reduce_with = |private_terms| {
   1750             RadrootsTradeReductionInputV1::new(trade_id())
   1751                 .with_mutations(vec![record(root.clone()), record(decision.clone())])
   1752                 .with_private_terms(private_terms)
   1753         };
   1754 
   1755         let first = reduce_trade_records(reduce_with(vec![
   1756             evidence(RadrootsTradePrivateTermsStateV1::AvailableVerified),
   1757             evidence(RadrootsTradePrivateTermsStateV1::CommitmentMismatch),
   1758         ]));
   1759         let second = reduce_trade_records(reduce_with(vec![
   1760             evidence(RadrootsTradePrivateTermsStateV1::CommitmentMismatch),
   1761             evidence(RadrootsTradePrivateTermsStateV1::AvailableVerified),
   1762         ]));
   1763 
   1764         assert_eq!(
   1765             first.private_terms_state,
   1766             RadrootsTradePrivateTermsStateV1::CommitmentMismatch
   1767         );
   1768         assert_eq!(first, second);
   1769     }
   1770 
   1771     #[test]
   1772     fn reducer_attestation_order_and_duplicates_do_not_change_digest() {
   1773         let root = proposal();
   1774         let decision = accepted_decision(&root, '1');
   1775         let valid = RadrootsTradeAttestationRecordV1::new(
   1776             event_id('8'),
   1777             root_id(&decision),
   1778             RadrootsTradeAttestationResultV1::Valid,
   1779         );
   1780         let invalid = RadrootsTradeAttestationRecordV1::new(
   1781             event_id('9'),
   1782             root_id(&decision),
   1783             RadrootsTradeAttestationResultV1::Invalid,
   1784         );
   1785         let reduce_with = |attestations| {
   1786             reduce_trade_records(
   1787                 RadrootsTradeReductionInputV1::new(trade_id())
   1788                     .with_mutations(vec![record(root.clone()), record(decision.clone())])
   1789                     .with_attestations(attestations),
   1790             )
   1791         };
   1792 
   1793         let first = reduce_with(vec![valid.clone(), invalid.clone(), valid.clone()]);
   1794         let second = reduce_with(vec![invalid, valid]);
   1795 
   1796         assert_eq!(
   1797             first.attestation_state,
   1798             RadrootsTradeAttestationStateV1::Conflicting
   1799         );
   1800         assert_eq!(first.attestations.len(), 2);
   1801         assert_eq!(first, second);
   1802     }
   1803 
   1804     #[test]
   1805     fn reducer_conformance_vectors_execute_deterministic_edge_cases() {
   1806         assert_eq!(PACKAGED_REDUCER_VECTORS, CANONICAL_REDUCER_VECTORS);
   1807         let document: serde_json::Value =
   1808             serde_json::from_str(PACKAGED_REDUCER_VECTORS).expect("reducer vectors parse");
   1809         assert_eq!(document["suite"], "trade");
   1810         assert_eq!(document["contract_version"], "1.0.0");
   1811         let vectors = document["vectors"]
   1812             .as_array()
   1813             .expect("reducer vector array");
   1814         assert_eq!(vectors.len(), 7);
   1815         let mut ids = BTreeSet::new();
   1816 
   1817         for vector in vectors {
   1818             let id = vector["id"].as_str().expect("reducer vector id");
   1819             assert!(ids.insert(id), "duplicate reducer vector {id}");
   1820             assert_eq!(vector["kind"], "trade.reduce_records", "{id}");
   1821             assert!(vector["input"].is_object(), "{id}: input must be object");
   1822             assert!(
   1823                 vector["expected"].is_object(),
   1824                 "{id}: expected must be object"
   1825             );
   1826 
   1827             match id {
   1828                 "trade_reduce_agreed_projection_digest_001" => {
   1829                     reducer_digest_is_independent_of_input_order_and_duplicates();
   1830                 }
   1831                 "trade_reduce_contested_claims_002" => {
   1832                     reducer_preserves_contested_incompatible_acceptances_without_timestamp_winner();
   1833                 }
   1834                 "trade_reduce_attestation_only_003" => {
   1835                     reducer_attestation_never_commits_or_invalidates_agreement();
   1836                 }
   1837                 "trade_reduce_missing_parent_004" => {
   1838                     reducer_keeps_missing_parents_as_incomplete_evidence();
   1839                 }
   1840                 "trade_reduce_unsupported_version_isolated_005" => {
   1841                     reducer_excludes_unsupported_versions_from_domain_semantics();
   1842                 }
   1843                 "trade_reduce_private_evidence_precedence_006" => {
   1844                     reducer_private_evidence_precedence_is_permutation_independent();
   1845                 }
   1846                 "trade_reduce_attestation_deduplication_007" => {
   1847                     reducer_attestation_order_and_duplicates_do_not_change_digest();
   1848                 }
   1849                 _ => panic!("unsupported reducer vector {id}"),
   1850             }
   1851         }
   1852     }
   1853 
   1854     #[test]
   1855     fn reducer_preserves_contested_incompatible_acceptances_without_timestamp_winner() {
   1856         let proposal = proposal();
   1857         let first = accepted_decision(&proposal, '1');
   1858         let second = accepted_decision(&proposal, '2');
   1859         let projection = reduce(vec![second.clone(), proposal, first.clone()]);
   1860 
   1861         assert_eq!(
   1862             projection.agreement_state,
   1863             RadrootsTradeAgreementStateV1::Contested
   1864         );
   1865         assert_eq!(
   1866             projection.conflict_state,
   1867             RadrootsTradeConflictStateV1::DoubleAcceptance
   1868         );
   1869         assert_eq!(projection.contested_claim_ids, {
   1870             let mut ids = vec![root_id(&first), root_id(&second)];
   1871             ids.sort();
   1872             ids
   1873         });
   1874     }
   1875 
   1876     #[test]
   1877     fn reducer_resolves_contested_state_only_with_new_causal_accepted_candidate() {
   1878         let proposal = proposal();
   1879         let first = accepted_decision(&proposal, '1');
   1880         let second = accepted_decision(&proposal, '2');
   1881         let revision = revision_proposal(&proposal, vec![root_id(&first), root_id(&second)]);
   1882         let revision_acceptance = revision_acceptance(&proposal, &revision);
   1883         let projection = reduce(vec![
   1884             second,
   1885             revision_acceptance.clone(),
   1886             proposal,
   1887             first,
   1888             revision,
   1889         ]);
   1890 
   1891         assert_eq!(
   1892             projection.agreement_state,
   1893             RadrootsTradeAgreementStateV1::Agreed
   1894         );
   1895         assert_eq!(
   1896             projection.active_agreement_claim_ids,
   1897             vec![root_id(&revision_acceptance)]
   1898         );
   1899         assert!(projection.contested_claim_ids.is_empty());
   1900     }
   1901 
   1902     #[test]
   1903     fn reducer_keeps_missing_parents_as_incomplete_evidence() {
   1904         let proposal = proposal();
   1905         let mut decision = accepted_decision(&proposal, '1');
   1906         let missing_parent = MutationId::parse(hex_64('e')).unwrap();
   1907         decision.parent_mutation_ids = vec![missing_parent];
   1908         let decision = canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
   1909             mutation_id: None,
   1910             ..decision
   1911         })
   1912         .unwrap()
   1913         .envelope;
   1914         let projection = reduce(vec![proposal, decision]);
   1915 
   1916         assert_eq!(
   1917             projection.evidence_state,
   1918             RadrootsTradeEvidenceStateV1::Missing
   1919         );
   1920         assert_eq!(projection.missing_parent_ids, vec![missing_parent]);
   1921         assert_eq!(
   1922             projection.agreement_state,
   1923             RadrootsTradeAgreementStateV1::Agreed
   1924         );
   1925     }
   1926 
   1927     #[test]
   1928     fn reducer_attestation_never_commits_or_invalidates_agreement() {
   1929         let proposal = proposal();
   1930         let decision = accepted_decision(&proposal, '1');
   1931         let claim_id = root_id(&decision);
   1932         let mut input = RadrootsTradeReductionInputV1::new(trade_id());
   1933         input.mutations = vec![record(proposal), record(decision)];
   1934         input.attestations = vec![RadrootsTradeAttestationRecordV1 {
   1935             event_id: event_id('9'),
   1936             claim_mutation_id: claim_id,
   1937             result: RadrootsTradeAttestationResultV1::Invalid,
   1938         }];
   1939         let projection = reduce_trade_records(input);
   1940 
   1941         assert_eq!(
   1942             projection.agreement_state,
   1943             RadrootsTradeAgreementStateV1::Agreed
   1944         );
   1945         assert_eq!(
   1946             projection.attestation_state,
   1947             RadrootsTradeAttestationStateV1::PresentInvalid
   1948         );
   1949     }
   1950 
   1951     #[test]
   1952     fn reducer_reports_causally_unordered_cancellation_conflict() {
   1953         let proposal = proposal();
   1954         let decision = accepted_decision(&proposal, '1');
   1955         let cancel = cancellation(&proposal, root_id(&decision), root_id(&proposal));
   1956         let projection = reduce(vec![proposal, decision, cancel]);
   1957 
   1958         assert_eq!(
   1959             projection.agreement_state,
   1960             RadrootsTradeAgreementStateV1::Contested
   1961         );
   1962         assert_eq!(
   1963             projection.conflict_state,
   1964             RadrootsTradeConflictStateV1::CancellationConflict
   1965         );
   1966     }
   1967 
   1968     #[test]
   1969     fn reducer_tracks_private_terms_without_hiding_claims() {
   1970         let mut root = proposal();
   1971         if let TradeMutationBodyV1::Proposal { candidate } = &mut root.body {
   1972             candidate.private_terms = Some(TradePrivateTermsRefV1 {
   1973                 artifact_id: "artifact-1".to_string(),
   1974                 schema_id: "radroots.private.fulfillment.v1".to_string(),
   1975                 ciphertext_commitment: hex_64('f'),
   1976                 required_acknowledgement: true,
   1977             });
   1978             candidate.fulfillment.requires_private_terms = true;
   1979         }
   1980         let root = canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
   1981             mutation_id: None,
   1982             ..root
   1983         })
   1984         .unwrap()
   1985         .envelope;
   1986         let decision = accepted_decision(&root, '1');
   1987         let projection = reduce(vec![root, decision]);
   1988 
   1989         assert_eq!(
   1990             projection.agreement_state,
   1991             RadrootsTradeAgreementStateV1::Agreed
   1992         );
   1993         assert_eq!(
   1994             projection.private_terms_state,
   1995             RadrootsTradePrivateTermsStateV1::Missing
   1996         );
   1997         assert_eq!(projection.active_agreement_claim_ids.len(), 1);
   1998     }
   1999 
   2000     #[test]
   2001     fn reducer_decline_and_expiry_are_negotiation_state_not_agreement_authority() {
   2002         let proposal = proposal();
   2003         let declined = declined_decision(&proposal);
   2004         let declined_projection = reduce(vec![proposal.clone(), declined]);
   2005         assert_eq!(
   2006             declined_projection.negotiation_state,
   2007             RadrootsTradeNegotiationStateV1::ClosedDeclined
   2008         );
   2009         assert_eq!(
   2010             declined_projection.agreement_state,
   2011             RadrootsTradeAgreementStateV1::None
   2012         );
   2013 
   2014         let mut input = RadrootsTradeReductionInputV1::new(trade_id());
   2015         input.mutations = vec![record(proposal)];
   2016         input.observed_at_unix_s = Some(1_900_000_000);
   2017         let expired_projection = reduce_trade_records(input);
   2018         assert_eq!(
   2019             expired_projection.negotiation_state,
   2020             RadrootsTradeNegotiationStateV1::ClosedExpired
   2021         );
   2022         assert_eq!(
   2023             expired_projection.agreement_state,
   2024             RadrootsTradeAgreementStateV1::None
   2025         );
   2026     }
   2027 
   2028     #[test]
   2029     fn reducer_classifies_malformed_unsupported_and_foreign_records() {
   2030         let empty = reduce(Vec::new());
   2031         assert_eq!(
   2032             empty.negotiation_state,
   2033             RadrootsTradeNegotiationStateV1::None
   2034         );
   2035         assert_eq!(empty.evidence_state, RadrootsTradeEvidenceStateV1::Missing);
   2036         assert!(
   2037             empty
   2038                 .issues
   2039                 .contains(&RadrootsTradeReducerIssueV1::MissingRootProposal)
   2040         );
   2041 
   2042         let mut missing_id = proposal();
   2043         missing_id.mutation_id = None;
   2044         let mut unsupported = proposal();
   2045         unsupported.schema_version += 1;
   2046         let unsupported_id = root_id(&unsupported);
   2047         let mut invalid = proposal();
   2048         invalid.contract_id = "invalid.contract".to_string();
   2049         let mut second_root = proposal();
   2050         second_root.authored_at_unix_s += 1;
   2051         let second_root = recanonicalize(second_root);
   2052         let projection = reduce(vec![
   2053             missing_id.clone(),
   2054             missing_id,
   2055             unsupported,
   2056             invalid,
   2057             proposal(),
   2058             second_root,
   2059         ]);
   2060         assert!(
   2061             projection
   2062                 .issues
   2063                 .contains(&RadrootsTradeReducerIssueV1::MissingMutationId)
   2064         );
   2065         assert!(projection.issues.iter().any(|issue| matches!(
   2066             issue,
   2067             RadrootsTradeReducerIssueV1::UnsupportedSchema { mutation_id, .. }
   2068                 if mutation_id == &unsupported_id
   2069         )));
   2070         assert!(
   2071             projection
   2072                 .issues
   2073                 .iter()
   2074                 .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::InvalidMutation { .. }))
   2075         );
   2076         assert!(
   2077             projection
   2078                 .issues
   2079                 .contains(&RadrootsTradeReducerIssueV1::MultipleRootProposals)
   2080         );
   2081         assert_eq!(
   2082             projection.conflict_state,
   2083             RadrootsTradeConflictStateV1::ConcurrentCandidates
   2084         );
   2085 
   2086         let foreign_trade = TradeId::parse(hex_32('2')).expect("foreign trade");
   2087         let mut input = RadrootsTradeReductionInputV1::new(foreign_trade);
   2088         input.mutations = vec![record(proposal())];
   2089         let foreign = reduce_trade_records(input);
   2090         assert!(foreign.issues.iter().any(|issue| matches!(
   2091             issue,
   2092             RadrootsTradeReducerIssueV1::TradeIdentityMismatch { .. }
   2093         )));
   2094     }
   2095 
   2096     #[test]
   2097     fn reducer_rejects_invalid_decision_relationships() {
   2098         let root = proposal();
   2099 
   2100         let missing_proposal = reduce(vec![accepted_decision(&root, '1')]);
   2101         assert!(
   2102             missing_proposal
   2103                 .issues
   2104                 .iter()
   2105                 .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::MissingProposal { .. }))
   2106         );
   2107 
   2108         let mut wrong_candidate = accepted_decision(&root, '1');
   2109         if let TradeMutationBodyV1::Decision { candidate_id, .. } = &mut wrong_candidate.body {
   2110             *candidate_id = CandidateId::parse(hex_64('f')).expect("candidate id");
   2111         }
   2112         let wrong_candidate = recanonicalize(wrong_candidate);
   2113         let projection = reduce(vec![root.clone(), wrong_candidate]);
   2114         assert!(projection.issues.iter().any(|issue| matches!(
   2115             issue,
   2116             RadrootsTradeReducerIssueV1::CandidateIdMismatch { .. }
   2117         )));
   2118 
   2119         let mut wrong_author = accepted_decision(&root, '2');
   2120         wrong_author.author_pubkey = wrong_author.buyer_pubkey;
   2121         wrong_author.counterparty_pubkey = wrong_author.seller_pubkey;
   2122         let wrong_author = recanonicalize(wrong_author);
   2123         let projection = reduce(vec![root.clone(), wrong_author]);
   2124         assert!(projection.issues.iter().any(|issue| matches!(
   2125             issue,
   2126             RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { .. }
   2127         )));
   2128 
   2129         let mut no_reservation = accepted_decision(&root, '3');
   2130         if let TradeMutationBodyV1::Decision { decision, .. } = &mut no_reservation.body {
   2131             *decision = TradeDecisionV1::Accepted {
   2132                 reservation_assertion: None,
   2133             };
   2134         }
   2135         let no_reservation = recanonicalize(no_reservation);
   2136         let projection = reduce(vec![root, no_reservation]);
   2137         assert!(projection.issues.iter().any(|issue| matches!(
   2138             issue,
   2139             RadrootsTradeReducerIssueV1::MissingSellerReservation { .. }
   2140         )));
   2141     }
   2142 
   2143     #[test]
   2144     fn reducer_rejects_every_reservation_mismatch() {
   2145         let root = proposal();
   2146         let mut mismatched = accepted_decision(&root, '4');
   2147         if let TradeMutationBodyV1::Decision {
   2148             decision:
   2149                 TradeDecisionV1::Accepted {
   2150                     reservation_assertion: Some(reservation),
   2151                 },
   2152             ..
   2153         } = &mut mismatched.body
   2154         {
   2155             reservation.candidate_id = CandidateId::parse(hex_64('f')).expect("candidate id");
   2156             reservation.inventory_authority_id = pubkey('a');
   2157             reservation.commitments[0].unit_code = "kg".to_string();
   2158         }
   2159         let mismatched = recanonicalize(mismatched);
   2160         let projection = reduce(vec![root.clone(), mismatched]);
   2161         assert!(projection.issues.iter().any(|issue| matches!(
   2162             issue,
   2163             RadrootsTradeReducerIssueV1::ReservationCandidateMismatch { .. }
   2164         )));
   2165         assert!(projection.issues.iter().any(|issue| matches!(
   2166             issue,
   2167             RadrootsTradeReducerIssueV1::ReservationAuthorityMismatch { .. }
   2168         )));
   2169         assert!(projection.issues.iter().any(|issue| matches!(
   2170             issue,
   2171             RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. }
   2172         )));
   2173         assert_eq!(
   2174             projection.conflict_state,
   2175             RadrootsTradeConflictStateV1::InventoryAuthorityConflict
   2176         );
   2177 
   2178         let mut wrong_count = accepted_decision(&root, '5');
   2179         if let TradeMutationBodyV1::Decision {
   2180             decision:
   2181                 TradeDecisionV1::Accepted {
   2182                     reservation_assertion: Some(reservation),
   2183                 },
   2184             ..
   2185         } = &mut wrong_count.body
   2186         {
   2187             let mut extra = reservation.commitments[0].clone();
   2188             extra.line_id = dtag("line-2");
   2189             reservation.commitments.push(extra);
   2190         }
   2191         let wrong_count = recanonicalize(wrong_count);
   2192         let projection = reduce(vec![root, wrong_count]);
   2193         assert!(projection.issues.iter().any(|issue| matches!(
   2194             issue,
   2195             RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. }
   2196         )));
   2197     }
   2198 
   2199     #[test]
   2200     fn reducer_covers_decision_conflict_and_ordered_cancellation() {
   2201         let root = proposal();
   2202         let accepted = accepted_decision(&root, '1');
   2203         let declined = declined_decision(&root);
   2204         let conflicted = reduce(vec![root.clone(), accepted.clone(), declined]);
   2205         assert_eq!(
   2206             conflicted.conflict_state,
   2207             RadrootsTradeConflictStateV1::DecisionConflict
   2208         );
   2209         assert!(
   2210             conflicted
   2211                 .issues
   2212                 .iter()
   2213                 .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::DecisionConflict { .. }))
   2214         );
   2215 
   2216         let cancel = cancellation(&root, root_id(&accepted), root_id(&accepted));
   2217         let cancelled = reduce(vec![root, accepted.clone(), cancel]);
   2218         assert_eq!(
   2219             cancelled.agreement_state,
   2220             RadrootsTradeAgreementStateV1::Cancelled
   2221         );
   2222         assert_eq!(cancelled.cancelled_claim_ids, vec![root_id(&accepted)]);
   2223     }
   2224 
   2225     #[test]
   2226     fn reducer_covers_pre_agreement_cancellation_and_requested_evidence() {
   2227         let root = proposal();
   2228         let cancel = candidate_cancellation(&root);
   2229         let cancelled = reduce(vec![root.clone(), cancel]);
   2230         assert_eq!(
   2231             cancelled.agreement_state,
   2232             RadrootsTradeAgreementStateV1::Cancelled
   2233         );
   2234 
   2235         let decision = accepted_decision(&root, '1');
   2236         let mut input = RadrootsTradeReductionInputV1::new(trade_id());
   2237         input.mutations = vec![record(root), record(decision)];
   2238         input.evidence_state = RadrootsTradeEvidenceStateV1::QueryPartial;
   2239         assert_eq!(
   2240             reduce_trade_records(input).evidence_state,
   2241             RadrootsTradeEvidenceStateV1::QueryPartial
   2242         );
   2243     }
   2244 
   2245     #[test]
   2246     fn reducer_covers_private_terms_and_attestation_precedence() {
   2247         let mut root = proposal();
   2248         if let TradeMutationBodyV1::Proposal { candidate } = &mut root.body {
   2249             candidate.private_terms = Some(TradePrivateTermsRefV1 {
   2250                 artifact_id: "artifact-1".to_string(),
   2251                 schema_id: "radroots.private.fulfillment.v1".to_string(),
   2252                 ciphertext_commitment: hex_64('f'),
   2253                 required_acknowledgement: true,
   2254             });
   2255         }
   2256         let root = recanonicalize(root);
   2257         let candidate_id = match &root.body {
   2258             TradeMutationBodyV1::Proposal { candidate } => {
   2259                 candidate.candidate_id.expect("candidate id")
   2260             }
   2261             _ => unreachable!(),
   2262         };
   2263         let decision = accepted_decision(&root, '1');
   2264         for (state, expected) in [
   2265             (
   2266                 RadrootsTradePrivateTermsStateV1::AvailableVerified,
   2267                 RadrootsTradePrivateTermsStateV1::AvailableVerified,
   2268             ),
   2269             (
   2270                 RadrootsTradePrivateTermsStateV1::Undecryptable,
   2271                 RadrootsTradePrivateTermsStateV1::Undecryptable,
   2272             ),
   2273             (
   2274                 RadrootsTradePrivateTermsStateV1::CommitmentMismatch,
   2275                 RadrootsTradePrivateTermsStateV1::CommitmentMismatch,
   2276             ),
   2277         ] {
   2278             let mut input = RadrootsTradeReductionInputV1::new(trade_id());
   2279             input.mutations = vec![record(root.clone()), record(decision.clone())];
   2280             input.private_terms = vec![RadrootsTradePrivateTermsEvidenceV1 {
   2281                 candidate_id,
   2282                 state,
   2283             }];
   2284             assert_eq!(reduce_trade_records(input).private_terms_state, expected);
   2285         }
   2286 
   2287         for (results, expected) in [
   2288             (
   2289                 vec![RadrootsTradeAttestationResultV1::Valid],
   2290                 RadrootsTradeAttestationStateV1::PresentValid,
   2291             ),
   2292             (
   2293                 vec![
   2294                     RadrootsTradeAttestationResultV1::Valid,
   2295                     RadrootsTradeAttestationResultV1::Invalid,
   2296                 ],
   2297                 RadrootsTradeAttestationStateV1::Conflicting,
   2298             ),
   2299         ] {
   2300             let mut input = RadrootsTradeReductionInputV1::new(trade_id());
   2301             input.mutations = vec![record(root.clone()), record(decision.clone())];
   2302             input.attestations = results
   2303                 .into_iter()
   2304                 .enumerate()
   2305                 .map(|(index, result)| RadrootsTradeAttestationRecordV1 {
   2306                     event_id: event_id(if index == 0 { '8' } else { '9' }),
   2307                     claim_mutation_id: root_id(&decision),
   2308                     result,
   2309                 })
   2310                 .collect();
   2311             assert_eq!(reduce_trade_records(input).attestation_state, expected);
   2312         }
   2313     }
   2314 
   2315     #[test]
   2316     fn reducer_private_helpers_cover_empty_graph_and_conflict_precedence() {
   2317         let missing = MutationId::parse(hex_64('e')).expect("mutation id");
   2318         assert!(ancestors_of(&missing, &BTreeMap::new(), &mut BTreeMap::new()).is_empty());
   2319 
   2320         let mut conflict = RadrootsTradeConflictStateV1::DecisionConflict;
   2321         set_conflict(
   2322             &mut conflict,
   2323             RadrootsTradeConflictStateV1::ConcurrentCandidates,
   2324         );
   2325         assert_eq!(conflict, RadrootsTradeConflictStateV1::DecisionConflict);
   2326         set_conflict(
   2327             &mut conflict,
   2328             RadrootsTradeConflictStateV1::InvalidCausalChain,
   2329         );
   2330         assert_eq!(conflict, RadrootsTradeConflictStateV1::InvalidCausalChain);
   2331     }
   2332 
   2333     #[test]
   2334     fn reducer_rejects_self_identified_decision_author_bypass() {
   2335         let root = proposal();
   2336         let mut decision = accepted_decision(&root, '7');
   2337         decision.author_pubkey = decision.buyer_pubkey;
   2338         decision.counterparty_pubkey = decision.buyer_pubkey;
   2339         let decision = recanonicalize(decision);
   2340 
   2341         let projection = reduce(vec![root, decision]);
   2342         assert!(projection.issues.iter().any(|issue| matches!(
   2343             issue,
   2344             RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { .. }
   2345         )));
   2346     }
   2347 
   2348     #[test]
   2349     fn reducer_covers_all_decline_and_counterparty_shapes() {
   2350         let root = proposal();
   2351         let first = declined_decision(&root);
   2352         let mut second = first.clone();
   2353         second.authored_at_unix_s += 1;
   2354         if let TradeMutationBodyV1::Decision {
   2355             decision: TradeDecisionV1::Declined { reason },
   2356             ..
   2357         } = &mut second.body
   2358         {
   2359             *reason = "still unavailable".to_string();
   2360         }
   2361         let second = recanonicalize(second);
   2362         let projection = reduce(vec![root.clone(), first, second]);
   2363         assert_eq!(
   2364             projection.negotiation_state,
   2365             RadrootsTradeNegotiationStateV1::ClosedDeclined
   2366         );
   2367 
   2368         let revision = revision_proposal(&root, vec![root_id(&root)]);
   2369         let mut revision_decline = declined_decision(&revision);
   2370         let TradeMutationBodyV1::Decision {
   2371             proposal_mutation_id,
   2372             candidate_id,
   2373             decision,
   2374         } = revision_decline.body
   2375         else {
   2376             unreachable!();
   2377         };
   2378         revision_decline.contract_id = RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID.to_string();
   2379         revision_decline.root_mutation_id = Some(root_id(&root));
   2380         revision_decline.body = TradeMutationBodyV1::RevisionDecision {
   2381             proposal_mutation_id,
   2382             candidate_id,
   2383             decision,
   2384         };
   2385         let revision_decline = recanonicalize(revision_decline);
   2386         assert_eq!(
   2387             declined_candidate_ids(&BTreeMap::from([(
   2388                 root_id(&revision_decline),
   2389                 revision_decline,
   2390             )])),
   2391             vec![candidate_id]
   2392         );
   2393 
   2394         let candidate = match &root.body {
   2395             TradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
   2396             _ => unreachable!(),
   2397         };
   2398         let mut candidate_record = CandidateRecord {
   2399             proposal_mutation_id: root_id(&root),
   2400             author_pubkey: root.buyer_pubkey,
   2401             candidate,
   2402         };
   2403         let decision = accepted_decision(&root, '8');
   2404         assert_eq!(
   2405             candidate_record_author_counterparty(&candidate_record, &decision),
   2406             decision.seller_pubkey
   2407         );
   2408         candidate_record.author_pubkey = decision.seller_pubkey;
   2409         assert_eq!(
   2410             candidate_record_author_counterparty(&candidate_record, &decision),
   2411             decision.buyer_pubkey
   2412         );
   2413     }
   2414 
   2415     #[test]
   2416     fn reservation_line_validation_checks_each_field() {
   2417         let root = proposal();
   2418         let candidate = match &root.body {
   2419             TradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
   2420             _ => unreachable!(),
   2421         };
   2422         let candidate_id = candidate.candidate_id.expect("candidate id");
   2423         let decision_mutation_id = root_id(&accepted_decision(&root, '9'));
   2424 
   2425         for field in 0..5 {
   2426             let mut reservation = reservation(&candidate, '9');
   2427             match field {
   2428                 0 => reservation.commitments[0].line_id = dtag("line-other"),
   2429                 1 => reservation.commitments[0].bin_id = bin_id("bin-other"),
   2430                 2 => reservation.commitments[0].quantity_mantissa = "3".to_string(),
   2431                 3 => reservation.commitments[0].quantity_scale = 1,
   2432                 4 => reservation.commitments[0].unit_code = "kg".to_string(),
   2433                 _ => unreachable!(),
   2434             }
   2435             let mut projection = RadrootsTradeProjectionV1::empty(trade_id());
   2436             assert!(!validate_reservation(
   2437                 &decision_mutation_id,
   2438                 &candidate_id,
   2439                 &candidate,
   2440                 &reservation,
   2441                 &mut projection,
   2442             ));
   2443             assert!(projection.issues.iter().any(|issue| matches!(
   2444                 issue,
   2445                 RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. }
   2446             )));
   2447         }
   2448     }
   2449 
   2450     #[test]
   2451     fn agreement_and_cancellation_helpers_cover_nonterminal_shapes() {
   2452         let root = proposal();
   2453         let first_decision = accepted_decision(&root, '1');
   2454         let second_decision = accepted_decision(&root, '2');
   2455         let first_id = root_id(&first_decision);
   2456         let second_id = root_id(&second_decision);
   2457         let candidate = match &root.body {
   2458             TradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
   2459             _ => unreachable!(),
   2460         };
   2461         let candidate_id = candidate.candidate_id.expect("candidate id");
   2462         let claim = |claim_mutation_id: MutationId| RadrootsTradeAgreementClaimV1 {
   2463             claim_mutation_id,
   2464             proposal_mutation_id: root_id(&root),
   2465             candidate_id,
   2466             candidate_author_pubkey: root.buyer_pubkey,
   2467             accepted_by_pubkey: root.seller_pubkey,
   2468             reservation_commitment: hex_64('a'),
   2469         };
   2470         let claims = BTreeMap::from([(first_id, claim(first_id)), (second_id, claim(second_id))]);
   2471         let mutations = BTreeMap::from([(first_id, first_decision), (second_id, second_decision)]);
   2472         let missing_claim = MutationId::parse(hex_64('e')).expect("missing claim");
   2473         let cancellations = vec![
   2474             CancellationRecord {
   2475                 mutation_id: MutationId::parse(hex_64('3')).expect("cancellation"),
   2476                 parent_mutation_ids: Vec::new(),
   2477                 target_candidate_id: None,
   2478                 target_claim_mutation_id: None,
   2479             },
   2480             CancellationRecord {
   2481                 mutation_id: MutationId::parse(hex_64('4')).expect("cancellation"),
   2482                 parent_mutation_ids: Vec::new(),
   2483                 target_candidate_id: None,
   2484                 target_claim_mutation_id: Some(missing_claim),
   2485             },
   2486             CancellationRecord {
   2487                 mutation_id: MutationId::parse(hex_64('5')).expect("cancellation"),
   2488                 parent_mutation_ids: vec![first_id],
   2489                 target_candidate_id: None,
   2490                 target_claim_mutation_id: Some(first_id),
   2491             },
   2492         ];
   2493         let mut projection = RadrootsTradeProjectionV1::empty(trade_id());
   2494         apply_agreement_state(
   2495             &mut projection,
   2496             &claims,
   2497             &mutations,
   2498             &BTreeMap::new(),
   2499             &cancellations,
   2500         );
   2501         assert_eq!(
   2502             projection.agreement_state,
   2503             RadrootsTradeAgreementStateV1::Agreed
   2504         );
   2505         assert_eq!(projection.cancelled_claim_ids, vec![first_id]);
   2506 
   2507         let mut incompatible_claims = claims.clone();
   2508         incompatible_claims
   2509             .get_mut(&second_id)
   2510             .expect("second claim")
   2511             .candidate_id = CandidateId::parse(hex_64('f')).expect("candidate");
   2512         let mut incompatible = RadrootsTradeProjectionV1::empty(trade_id());
   2513         apply_agreement_state(
   2514             &mut incompatible,
   2515             &incompatible_claims,
   2516             &mutations,
   2517             &BTreeMap::new(),
   2518             &[],
   2519         );
   2520         assert_eq!(
   2521             incompatible.conflict_state,
   2522             RadrootsTradeConflictStateV1::DecisionConflict
   2523         );
   2524 
   2525         let candidates = BTreeMap::from([(
   2526             root_id(&root),
   2527             CandidateRecord {
   2528                 proposal_mutation_id: root_id(&root),
   2529                 author_pubkey: root.author_pubkey,
   2530                 candidate: candidate.clone(),
   2531             },
   2532         )]);
   2533         let unknown_candidate = CandidateId::parse(hex_64('f')).expect("candidate");
   2534         assert!(!cancellation_without_claim(
   2535             &[CancellationRecord {
   2536                 mutation_id: MutationId::parse(hex_64('6')).expect("cancellation"),
   2537                 parent_mutation_ids: Vec::new(),
   2538                 target_candidate_id: Some(unknown_candidate),
   2539                 target_claim_mutation_id: None,
   2540             }],
   2541             &candidates,
   2542         ));
   2543         let mut disabled_candidate = candidate;
   2544         disabled_candidate.cancellation.buyer_pre_agreement = false;
   2545         let disabled_candidates = BTreeMap::from([(
   2546             root_id(&root),
   2547             CandidateRecord {
   2548                 proposal_mutation_id: root_id(&root),
   2549                 author_pubkey: root.author_pubkey,
   2550                 candidate: disabled_candidate,
   2551             },
   2552         )]);
   2553         assert!(!cancellation_without_claim(
   2554             &[CancellationRecord {
   2555                 mutation_id: MutationId::parse(hex_64('7')).expect("cancellation"),
   2556                 parent_mutation_ids: Vec::new(),
   2557                 target_candidate_id: Some(candidate_id),
   2558                 target_claim_mutation_id: None,
   2559             }],
   2560             &disabled_candidates,
   2561         ));
   2562     }
   2563 
   2564     #[test]
   2565     fn evidence_state_covers_missing_proposal_independently() {
   2566         let root = proposal();
   2567         let mut projection = RadrootsTradeProjectionV1::empty(trade_id());
   2568         projection.root_mutation_id = Some(root_id(&root));
   2569         projection
   2570             .missing_proposal_ids
   2571             .push(MutationId::parse(hex_64('e')).expect("proposal"));
   2572         assert_eq!(
   2573             reduce_evidence_state(&projection, RadrootsTradeEvidenceStateV1::Complete),
   2574             RadrootsTradeEvidenceStateV1::Missing
   2575         );
   2576     }
   2577 
   2578     #[test]
   2579     fn passive_reducer_types_expose_every_governed_accessor() {
   2580         let root = proposal();
   2581         let mutation_id = root.mutation_id.expect("mutation id");
   2582         let candidate_id = match &root.body {
   2583             TradeMutationBodyV1::Proposal { candidate } => {
   2584                 candidate.candidate_id.expect("candidate")
   2585             }
   2586             _ => unreachable!(),
   2587         };
   2588         let transport_event_id = event_id('e');
   2589         let record = RadrootsTradeMutationRecordV1::new(Some(transport_event_id), root.clone());
   2590         assert_eq!(record.transport_event_id(), Some(&transport_event_id));
   2591         assert_eq!(record.mutation(), &root);
   2592 
   2593         let private = RadrootsTradePrivateTermsEvidenceV1::new(
   2594             candidate_id,
   2595             RadrootsTradePrivateTermsStateV1::AvailableVerified,
   2596         );
   2597         assert_eq!(private.candidate_id(), &candidate_id);
   2598         assert_eq!(
   2599             private.state(),
   2600             RadrootsTradePrivateTermsStateV1::AvailableVerified
   2601         );
   2602         let attestation = RadrootsTradeAttestationRecordV1::new(
   2603             event_id('f'),
   2604             mutation_id,
   2605             RadrootsTradeAttestationResultV1::Valid,
   2606         );
   2607         assert_eq!(attestation.event_id(), &event_id('f'));
   2608         assert_eq!(attestation.claim_mutation_id(), &mutation_id);
   2609         assert_eq!(
   2610             attestation.result(),
   2611             RadrootsTradeAttestationResultV1::Valid
   2612         );
   2613 
   2614         let input = RadrootsTradeReductionInputV1::new(trade_id())
   2615             .with_mutations(vec![record])
   2616             .with_private_terms(vec![private])
   2617             .with_attestations(vec![attestation])
   2618             .with_evidence_state(RadrootsTradeEvidenceStateV1::QueryPartial)
   2619             .with_observed_at_unix_s(Some(123));
   2620         assert_eq!(input.trade_id(), &trade_id());
   2621         assert_eq!(input.mutations().len(), 1);
   2622         assert_eq!(input.private_terms().len(), 1);
   2623         assert_eq!(input.attestations().len(), 1);
   2624         assert_eq!(
   2625             input.evidence_state(),
   2626             RadrootsTradeEvidenceStateV1::QueryPartial
   2627         );
   2628         assert_eq!(input.observed_at_unix_s(), Some(123));
   2629 
   2630         let projection = reduce_trade_records(input);
   2631         assert_eq!(
   2632             projection.reducer_contract_id(),
   2633             RADROOTS_TRADE_REDUCER_CONTRACT_ID
   2634         );
   2635         assert_eq!(projection.reducer_version(), RADROOTS_TRADE_REDUCER_VERSION);
   2636         assert_eq!(projection.trade_id(), &trade_id());
   2637         let _ = projection.root_mutation_id();
   2638         let _ = projection.buyer_pubkey();
   2639         let _ = projection.seller_pubkey();
   2640         let _ = projection.farm_id();
   2641         let _ = projection.negotiation_state();
   2642         let _ = projection.agreement_state();
   2643         let _ = projection.evidence_state();
   2644         let _ = projection.conflict_state();
   2645         let _ = projection.private_terms_state();
   2646         let _ = projection.attestation_state();
   2647         let _ = projection.fulfillment_state();
   2648         let _ = projection.payment_state();
   2649         let _ = projection.candidate_heads();
   2650         let _ = projection.agreement_claims();
   2651         let _ = projection.active_agreement_claim_ids();
   2652         let _ = projection.contested_claim_ids();
   2653         let _ = projection.cancelled_claim_ids();
   2654         let _ = projection.declined_candidate_ids();
   2655         let _ = projection.missing_parent_ids();
   2656         let _ = projection.missing_proposal_ids();
   2657         let _ = projection.unsupported_mutation_ids();
   2658         let _ = projection.issues();
   2659         let _ = projection.attestations();
   2660         assert!(!projection.projection_digest().is_empty());
   2661     }
   2662 }