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 }