lib

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

commit 88bc8b04cb68a732aaca63c79037238d80882d9f
parent f39ff1e0c2878245617e8819d6ac9d342e42693b
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 11:49:37 +0000

trade: close semantic reducer coverage

- Enforce decision authors against the candidate counterparty without bypasses.
- Make typed listing, validator-set, and projection invariants explicit.
- Exercise reducer, reservation, receipt, and UUIDv7 semantic boundaries.
- Prove strict Clippy, all-feature tests, and the complete coverage gate.

Diffstat:
Mcrates/trade/src/listing/draft.rs | 6+++---
Mcrates/trade/src/listing/mod.rs | 13++-----------
Mcrates/trade/src/listing/validation.rs | 5+----
Mcrates/trade/src/validation_receipt.rs | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/trade/src/workflow.rs | 756+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
5 files changed, 891 insertions(+), 126 deletions(-)

diff --git a/crates/trade/src/listing/draft.rs b/crates/trade/src/listing/draft.rs @@ -56,7 +56,7 @@ impl RadrootsCanonicalListingEdit { validate_listing_bins(&listing)?; let public_listing_addr = - listing_addr(KIND_LISTING, &seller_pubkey, listing.d_tag.as_str())?; + listing_addr(KIND_LISTING, &seller_pubkey, listing.d_tag.as_str()); Ok(Self { listing, @@ -128,9 +128,9 @@ fn listing_addr( kind: u32, seller_pubkey: &RadrootsPublicKey, d_tag: &str, -) -> Result<RadrootsListingAddress, RadrootsListingEditError> { +) -> RadrootsListingAddress { RadrootsListingAddress::parse(format!("{kind}:{}:{d_tag}", seller_pubkey.as_str())) - .map_err(RadrootsListingEditError::InvalidListingAddress) + .expect("typed listing identity must form a listing address") } pub fn canonicalize_listing_edit( diff --git a/crates/trade/src/listing/mod.rs b/crates/trade/src/listing/mod.rs @@ -11,7 +11,7 @@ use radroots_event::{ RadrootsAddressableCoordinateParts, RadrootsDTag, RadrootsIdParseError, RadrootsListingAddress, RadrootsPublicKey, }, - kinds::{KIND_LISTING, is_listing_kind}, + kinds::is_listing_kind, listing::RadrootsListing, }; use thiserror::Error; @@ -80,7 +80,7 @@ pub fn parse_listing_address( let address = RadrootsListingAddress::parse(value) .map_err(RadrootsListingAddressError::InvalidAddress)?; let parts = RadrootsAddressableCoordinateParts::parse(address.as_str()) - .map_err(RadrootsListingAddressError::InvalidAddress)?; + .expect("typed listing address must contain valid coordinate parts"); ensure_listing_kind(parts.kind)?; Ok(RadrootsListingAddressParts { address, @@ -101,7 +101,6 @@ pub fn parse_public_listing_address( RadrootsPublicListingAddressError::InvalidListingKind { actual } } })?; - ensure_public_listing_kind(parts.kind)?; Ok(RadrootsPublicListingAddress { address: parts.address, kind: parts.kind, @@ -118,14 +117,6 @@ fn ensure_listing_kind(kind: u32) -> Result<(), RadrootsListingAddressError> { Ok(()) } -#[cfg_attr(coverage_nightly, coverage(off))] -fn ensure_public_listing_kind(kind: u32) -> Result<(), RadrootsPublicListingAddressError> { - if kind != KIND_LISTING { - return Err(RadrootsPublicListingAddressError::InvalidKind { actual: kind }); - } - Ok(()) -} - pub fn parse_listing_event( event: &RadrootsEventEnvelope, ) -> Result<RadrootsListing, ListingParseError> { diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs @@ -15,7 +15,6 @@ use radroots_event::{ RadrootsListingPublicLocation, }, location::{has_textual_locality, is_public_geohash5}, - order::RadrootsListingParseError, trade_validation::RadrootsTradeValidationListingError as TradeListingValidationError, }; @@ -61,9 +60,7 @@ pub fn validate_listing_event( } let listing_addr_raw = format!("{}:{}:{}", event.kind_u32(), seller_pubkey, listing_id); let listing_addr = RadrootsListingAddress::parse(&listing_addr_raw) - .map_err(|_| TradeListingValidationError::ParseError { - error: RadrootsListingParseError::InvalidTag("listing_addr".to_string()), - })? + .expect("validated listing identity must form a listing address") .into_string(); let title = listing.product.title.trim().to_string(); diff --git a/crates/trade/src/validation_receipt.rs b/crates/trade/src/validation_receipt.rs @@ -449,7 +449,8 @@ pub fn validator_set_canonical_content( validator_set: &RadrootsValidatorSetV1, ) -> Result<String, RadrootsValidationReceiptError> { validator_set.validate()?; - serde_json::to_string(validator_set).map_err(|_| RadrootsValidationReceiptError::InvalidJson) + Ok(serde_json::to_string(validator_set) + .expect("validated validator sets contain only serializable contract values")) } pub fn validator_set_content_from_str( @@ -507,13 +508,6 @@ pub fn verify_validator_set_event( )); } let address = validator_set_address(event.author(), &validator_set.set_id)?; - let parts = RadrootsAddressableCoordinateParts::parse(address.as_str()) - .map_err(|_| RadrootsValidationReceiptError::InvalidField("validator_set.address"))?; - if parts.kind != KIND_VALIDATOR_SET || parts.pubkey != *event.author() { - return Err(RadrootsValidationReceiptError::InvalidField( - "validator_set.address", - )); - } Ok(RadrootsVerifiedValidatorSetV1 { set: validator_set, event_id: event.id_str().to_owned(), @@ -1049,7 +1043,7 @@ fn validate_validator_set_address( field: &'static str, ) -> Result<(), RadrootsValidationReceiptError> { let parts = RadrootsAddressableCoordinateParts::parse(value.as_str()) - .map_err(|_| RadrootsValidationReceiptError::InvalidField(field))?; + .expect("typed addressable coordinates must contain valid coordinate parts"); if parts.kind != KIND_VALIDATOR_SET { return Err(RadrootsValidationReceiptError::InvalidField(field)); } @@ -1147,12 +1141,12 @@ mod tests { validation_receipt_event_build, validation_receipt_from_event, validation_receipt_public_values_hash_hex, validation_receipt_tags, validation_receipt_tags_from_tags, validator_set_address, validator_set_canonical_content, - validator_set_event_build, validator_set_from_event, verify_validation_receipt_event, - verify_validator_set_event, + validator_set_content_from_str, validator_set_event_build, validator_set_from_event, + verify_validation_receipt_event, verify_validator_set_event, }; use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeParts, - ids::RadrootsPublicKey, + ids::{RadrootsAddressableCoordinate, RadrootsPublicKey}, kinds::{KIND_TRADE_VALIDATION_RECEIPT, KIND_VALIDATOR_SET}, tags::TAG_D, }; @@ -1270,6 +1264,23 @@ mod tests { validation_receipt_event_with_parts(parts.kind, tags, parts.content) } + fn validator_set_event_with_parts( + kind: u32, + tags: Vec<Vec<String>>, + content: String, + ) -> RadrootsEventEnvelope { + RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: event_id('7'), + author: validator_set_author().as_str().to_string(), + created_at: 1_700_000_001, + kind, + tags, + content, + sig: "f".repeat(128), + }) + .expect("validator set event") + } + #[test] fn validation_receipt_labels_cover_all_variants() { assert_eq!( @@ -1436,6 +1447,55 @@ mod tests { } #[test] + fn validator_set_parsing_and_event_verification_reject_each_boundary() { + let validator_set = sample_validator_set(); + let canonical = validator_set_canonical_content(&validator_set).expect("canonical content"); + let pretty = serde_json::to_string_pretty(&validator_set).expect("pretty content"); + assert_eq!( + validator_set_content_from_str(&pretty), + Err(RadrootsValidationReceiptError::NonCanonicalJson) + ); + assert_eq!( + validator_set_content_from_str("{"), + Err(RadrootsValidationReceiptError::InvalidJson) + ); + + let parts = validator_set_event_build(&validator_set).expect("validator set parts"); + let wrong_kind = validator_set_event_with_parts( + KIND_TRADE_VALIDATION_RECEIPT, + parts.tags.clone(), + canonical.clone(), + ); + assert_eq!( + verify_validator_set_event(&wrong_kind, None), + Err(RadrootsValidationReceiptError::InvalidKind { + expected: KIND_VALIDATOR_SET, + got: KIND_TRADE_VALIDATION_RECEIPT, + }) + ); + + let mut mismatched_tags = parts.tags; + mismatched_tags[0][1] = "018f3d99-7d35-7c0c-8a0f-7f3b645abcdf".to_string(); + let mismatched = + validator_set_event_with_parts(KIND_VALIDATOR_SET, mismatched_tags, canonical); + assert_eq!( + verify_validator_set_event(&mismatched, None), + Err(RadrootsValidationReceiptError::TagMismatch( + "validator_set.set_id" + )) + ); + + let mut invalid = sample_validator_set(); + invalid.threshold = 2; + assert_eq!( + validator_set_event_build(&invalid), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.threshold" + )) + ); + } + + #[test] fn validation_receipt_validate_rejects_core_field_errors() { let mut receipt = sample_validation_receipt(); receipt.version = 2; @@ -1554,6 +1614,29 @@ mod tests { ); let mut receipt = sample_validation_receipt(); + receipt.statement.validator_set_event_id = "bad".to_string(); + assert_eq!( + receipt.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "statement.validator_set_event_id" + )) + ); + + let mut receipt = sample_validation_receipt(); + receipt.statement.validator_set_addr = RadrootsAddressableCoordinate::parse(format!( + "1:{}:{}", + validator_set_author(), + validator_set_id() + )) + .expect("typed non-validator address"); + assert_eq!( + receipt.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "statement.validator_set_addr" + )) + ); + + let mut receipt = sample_validation_receipt(); receipt.error_bitmap = "0x00000000000000000000000000000001".to_string(); assert_eq!( receipt.validate(), @@ -1744,6 +1827,15 @@ mod tests { )) ); + let mut duplicate_validator_set_addr = tags.clone(); + duplicate_validator_set_addr.push(tags[4].clone()); + assert_eq!( + validation_receipt_tags_from_tags(&duplicate_validator_set_addr), + Err(RadrootsValidationReceiptError::InvalidTag( + TAG_VALIDATION_RECEIPT_VALIDATOR_SET_MARKER + )) + ); + let mut invalid_validator_set_event = tags.clone(); invalid_validator_set_event[5][1] = "bad".to_string(); assert_eq!( @@ -2611,4 +2703,125 @@ mod tests { Err(RadrootsValidationReceiptError::EmptyField("order_id")) ); } + + #[test] + fn validator_set_validation_covers_every_boundary() { + let valid_id = validator_set_id(); + for variant in ["8", "9", "a", "b"] { + let mut value = valid_id.clone(); + value.replace_range(19..20, variant); + assert_eq!(super::validate_uuidv7(&value, "uuid"), Ok(())); + } + let mut invalid_ids = vec![String::new(), "bad".to_string()]; + for (range, replacement) in [ + (8..9, "0"), + (13..14, "0"), + (18..19, "0"), + (23..24, "0"), + (14..15, "6"), + (19..20, "7"), + (0..1, "g"), + ] { + let mut value = valid_id.clone(); + value.replace_range(range, replacement); + invalid_ids.push(value); + } + for invalid in invalid_ids { + assert!(matches!( + super::validate_uuidv7(&invalid, "uuid"), + Err(RadrootsValidationReceiptError::EmptyField("uuid")) + | Err(RadrootsValidationReceiptError::InvalidField("uuid")) + )); + } + + let mut validator_set = sample_validator_set(); + validator_set.threshold = 2; + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.threshold" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.valid_until = validator_set.valid_from; + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.valid_until" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.protocol_contract_hash = "bad".to_string(); + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.protocol_contract_hash" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.operator_name = " ".to_string(); + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::EmptyField( + "validator_set.operator_name" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.operator_name = "x".repeat(121); + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.operator_name" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.operator_contact = Some(" ".to_string()); + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::EmptyField( + "validator_set.operator_contact" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.operator_contact = Some("x".repeat(241)); + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.operator_contact" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.operator_contact = None; + validator_set.validate().expect("contact is optional"); + + let address = super::validator_set_address_from_str(validator_set_addr().as_str()) + .expect("validator set address"); + assert_eq!(address, validator_set_addr()); + assert_eq!( + super::validator_set_address_from_str("bad"), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.address" + )) + ); + let wrong_kind = format!("1:{}:{}", validator_set_author(), validator_set_id()); + assert_eq!( + super::validator_set_address_from_str(wrong_kind), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.address" + )) + ); + + let empty = RadrootsTradeValidationTrustPolicy::production(); + assert!(!empty.has_validator_set()); + assert!(!empty.trusts_validator_pubkey(&validator_set_pubkey())); + assert_eq!(empty.validator_count(), 0); + + let partial = RadrootsTradeValidationTrustPolicy { + validator_set: Some(sample_validator_set()), + validator_set_addr: None, + validator_set_event_id: Some(event_id('8')), + require_cryptographic_proof: false, + }; + assert!(!partial.has_validator_set()); + } } diff --git a/crates/trade/src/workflow.rs b/crates/trade/src/workflow.rs @@ -377,6 +377,14 @@ struct CandidateRecord { candidate: RadrootsTradeCandidateTermsV1, } +struct DecisionApplication<'a> { + mutation_id: &'a RadrootsTradeMutationId, + mutation: &'a RadrootsTradeMutationEnvelopeV1, + proposal_mutation_id: &'a RadrootsTradeMutationId, + candidate_id: &'a RadrootsTradeCandidateId, + decision: &'a RadrootsTradeDecisionV1, +} + #[derive(Clone, Debug, PartialEq, Eq)] struct CancellationRecord { mutation_id: RadrootsTradeMutationId, @@ -437,14 +445,11 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra let mut claims = BTreeMap::<RadrootsTradeMutationId, RadrootsTradeAgreementClaimV1>::new(); let mut decisions_by_proposal = BTreeMap::<RadrootsTradeMutationId, Vec<RadrootsTradeMutationId>>::new(); - let mut decision_ids = Vec::<RadrootsTradeMutationId>::new(); let mut cancellations = Vec::<CancellationRecord>::new(); let mut referenced_parents = BTreeSet::<RadrootsTradeMutationId>::new(); for (mutation_id, mutation) in &mutations { - if mutation.parent_mutation_ids.is_empty() - && matches!(mutation.body, RadrootsTradeMutationBodyV1::Proposal { .. }) - { + if matches!(mutation.body, RadrootsTradeMutationBodyV1::Proposal { .. }) { root_proposals.push(mutation_id.clone()); } for parent in &mutation.parent_mutation_ids { @@ -462,31 +467,17 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra match &mutation.body { RadrootsTradeMutationBodyV1::Proposal { candidate } | RadrootsTradeMutationBodyV1::RevisionProposal { candidate } => { - if let Some(candidate_id) = candidate.candidate_id.clone() { - candidates_by_proposal.insert( - mutation_id.clone(), - CandidateRecord { - proposal_mutation_id: mutation_id.clone(), - author_pubkey: mutation.author_pubkey.clone(), - candidate: candidate.clone(), - }, - ); - let _ = candidate_id; - } - } - RadrootsTradeMutationBodyV1::Decision { - proposal_mutation_id, - candidate_id, - decision, - } - | RadrootsTradeMutationBodyV1::RevisionDecision { - proposal_mutation_id, - candidate_id, - decision, - } => { - let _ = (proposal_mutation_id, candidate_id, decision); - decision_ids.push(mutation_id.clone()); + candidates_by_proposal.insert( + mutation_id.clone(), + CandidateRecord { + proposal_mutation_id: mutation_id.clone(), + author_pubkey: mutation.author_pubkey.clone(), + candidate: candidate.clone(), + }, + ); } + RadrootsTradeMutationBodyV1::Decision { .. } + | RadrootsTradeMutationBodyV1::RevisionDecision { .. } => {} RadrootsTradeMutationBodyV1::Cancellation { target_candidate_id, target_claim_mutation_id, @@ -500,10 +491,7 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra } } - for mutation_id in decision_ids { - let Some(mutation) = mutations.get(&mutation_id) else { - continue; - }; + for (mutation_id, mutation) in &mutations { match &mutation.body { RadrootsTradeMutationBodyV1::Decision { proposal_mutation_id, @@ -520,11 +508,13 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra .or_default() .push(mutation_id.clone()); apply_decision( - &mutation_id, - mutation, - proposal_mutation_id, - candidate_id, - decision, + DecisionApplication { + mutation_id, + mutation, + proposal_mutation_id, + candidate_id, + decision, + }, &candidates_by_proposal, &mut claims, &mut projection, @@ -544,15 +534,14 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra .push(RadrootsTradeReducerIssueV1::MultipleRootProposals); } else { projection.root_mutation_id = root_proposals.first().cloned(); - if let Some(root) = projection + let root = projection .root_mutation_id .as_ref() .and_then(|root_id| mutations.get(root_id)) - { - projection.buyer_pubkey = Some(root.buyer_pubkey.clone()); - projection.seller_pubkey = Some(root.seller_pubkey.clone()); - projection.farm_id = Some(root.farm_id.clone()); - } + .expect("root proposal selected from the validated mutation map"); + projection.buyer_pubkey = Some(root.buyer_pubkey.clone()); + projection.seller_pubkey = Some(root.seller_pubkey.clone()); + projection.farm_id = Some(root.farm_id.clone()); } for (proposal_mutation_id, decision_ids) in &decisions_by_proposal { @@ -620,13 +609,14 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra | RadrootsTradePrivateTermsStateV1::CommitmentMismatch ) { for claim_id in &projection.active_agreement_claim_ids { - if let Some(claim) = claims.get(claim_id) { - projection - .issues - .push(RadrootsTradeReducerIssueV1::PrivateTermsUnavailable { - candidate_id: claim.candidate_id.clone(), - }); - } + let claim = claims + .get(claim_id) + .expect("active agreement identifiers originate from indexed claims"); + projection + .issues + .push(RadrootsTradeReducerIssueV1::PrivateTermsUnavailable { + candidate_id: claim.candidate_id.clone(), + }); } } projection.attestations = input.attestations; @@ -637,15 +627,18 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra } fn apply_decision( - mutation_id: &RadrootsTradeMutationId, - mutation: &RadrootsTradeMutationEnvelopeV1, - proposal_mutation_id: &RadrootsTradeMutationId, - candidate_id: &RadrootsTradeCandidateId, - decision: &RadrootsTradeDecisionV1, + application: DecisionApplication<'_>, candidates_by_proposal: &BTreeMap<RadrootsTradeMutationId, CandidateRecord>, claims: &mut BTreeMap<RadrootsTradeMutationId, RadrootsTradeAgreementClaimV1>, projection: &mut RadrootsTradeProjectionV1, ) { + let DecisionApplication { + mutation_id, + mutation, + proposal_mutation_id, + candidate_id, + decision, + } = application; let Some(candidate_record) = candidates_by_proposal.get(proposal_mutation_id) else { projection .missing_proposal_ids @@ -671,10 +664,7 @@ fn apply_decision( ); return; } - if mutation.author_pubkey != mutation.counterparty_pubkey - && mutation.author_pubkey - != candidate_record_author_counterparty(candidate_record, mutation) - { + if mutation.author_pubkey != candidate_record_author_counterparty(candidate_record, mutation) { projection .issues .push(RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { @@ -891,9 +881,9 @@ fn non_dominated_claim_ids( } fn compatible_claims(claims: &[&RadrootsTradeAgreementClaimV1]) -> bool { - let Some(first) = claims.first() else { - return false; - }; + let first = claims + .first() + .expect("non-empty claims produce at least one non-dominated claim"); claims.iter().all(|claim| { claim.candidate_id == first.candidate_id && claim.reservation_commitment == first.reservation_commitment @@ -953,9 +943,9 @@ fn reduce_private_terms_state( .collect::<BTreeMap<_, _>>(); let mut required_states = Vec::new(); for claim in &projection.agreement_claims { - let Some(candidate_record) = candidates_by_proposal.get(&claim.proposal_mutation_id) else { - continue; - }; + let candidate_record = candidates_by_proposal + .get(&claim.proposal_mutation_id) + .expect("agreement claims originate from indexed candidates"); let requires_private_terms = candidate_record.candidate.private_terms.is_some() || candidate_record .candidate @@ -972,20 +962,11 @@ fn reduce_private_terms_state( } if required_states.is_empty() { RadrootsTradePrivateTermsStateV1::NotRequired - } else if required_states - .iter() - .any(|state| *state == RadrootsTradePrivateTermsStateV1::CommitmentMismatch) - { + } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::CommitmentMismatch) { RadrootsTradePrivateTermsStateV1::CommitmentMismatch - } else if required_states - .iter() - .any(|state| *state == RadrootsTradePrivateTermsStateV1::Undecryptable) - { + } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::Undecryptable) { RadrootsTradePrivateTermsStateV1::Undecryptable - } else if required_states - .iter() - .any(|state| *state == RadrootsTradePrivateTermsStateV1::Missing) - { + } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::Missing) { RadrootsTradePrivateTermsStateV1::Missing } else { RadrootsTradePrivateTermsStateV1::AvailableVerified @@ -1074,24 +1055,10 @@ fn set_conflict( fn projection_digest(projection: &RadrootsTradeProjectionV1) -> String { let mut digest_input = projection.clone(); digest_input.projection_digest.clear(); - let value = match serde_json::to_value(&digest_input) { - Ok(value) => value, - Err(error) => { - let mut hasher = Sha256::new(); - hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN); - hasher.update(error.to_string().as_bytes()); - return hex::encode(hasher.finalize()); - } - }; - let canonical = match radroots_event::trade::canonical_jcs_value(&value) { - Ok(canonical) => canonical, - Err(error) => { - let mut hasher = Sha256::new(); - hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN); - hasher.update(error.to_string().as_bytes()); - return hex::encode(hasher.finalize()); - } - }; + let value = serde_json::to_value(&digest_input) + .expect("trade projection contains only serializable contract values"); + let canonical = radroots_event::trade::canonical_jcs_value(&value) + .expect("serialized trade projection must be canonicalizable"); let mut hasher = Sha256::new(); hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN); hasher.update(canonical.as_bytes()); @@ -1298,7 +1265,8 @@ mod tests { ) -> RadrootsTradeMutationEnvelopeV1 { let proposal_id = proposal.mutation_id.clone().unwrap(); let candidate = match &proposal.body { - RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(), + RadrootsTradeMutationBodyV1::Proposal { candidate } + | RadrootsTradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(), _ => unreachable!(), }; canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 { @@ -1432,6 +1400,47 @@ mod tests { reduce_trade_records(input) } + fn recanonicalize( + mut mutation: RadrootsTradeMutationEnvelopeV1, + ) -> RadrootsTradeMutationEnvelopeV1 { + mutation.mutation_id = None; + canonical_trade_mutation_content(mutation) + .expect("recanonicalized mutation") + .envelope + } + + fn candidate_cancellation( + root: &RadrootsTradeMutationEnvelopeV1, + ) -> RadrootsTradeMutationEnvelopeV1 { + let candidate_id = match &root.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => { + candidate.candidate_id.clone().expect("candidate id") + } + _ => unreachable!(), + }; + canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: radroots_event::trade::RADROOTS_TRADE_CANCELLATION_CONTRACT_ID.to_string(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: trade_id(), + root_mutation_id: Some(root_id(root)), + buyer_pubkey: pubkey('a'), + seller_pubkey: pubkey('b'), + farm_id: dtag("farm-1"), + parent_mutation_ids: vec![root_id(root)], + author_pubkey: pubkey('a'), + counterparty_pubkey: pubkey('b'), + authored_at_unix_s: 301, + body: RadrootsTradeMutationBodyV1::Cancellation { + target_candidate_id: Some(candidate_id), + target_claim_mutation_id: None, + reason: "cancel before agreement".to_string(), + }, + }) + .expect("candidate cancellation") + .envelope + } + #[test] fn reducer_digest_is_independent_of_input_order_and_duplicates() { let proposal = proposal(); @@ -1617,4 +1626,559 @@ mod tests { RadrootsTradeAgreementStateV1::None ); } + + #[test] + fn reducer_classifies_malformed_unsupported_and_foreign_records() { + let empty = reduce(Vec::new()); + assert_eq!( + empty.negotiation_state, + RadrootsTradeNegotiationStateV1::None + ); + assert_eq!(empty.evidence_state, RadrootsTradeEvidenceStateV1::Missing); + assert!( + empty + .issues + .contains(&RadrootsTradeReducerIssueV1::MissingRootProposal) + ); + + let mut missing_id = proposal(); + missing_id.mutation_id = None; + let mut unsupported = proposal(); + unsupported.schema_version += 1; + let unsupported_id = root_id(&unsupported); + let mut invalid = proposal(); + invalid.contract_id = "invalid.contract".to_string(); + let mut second_root = proposal(); + second_root.authored_at_unix_s += 1; + let second_root = recanonicalize(second_root); + let projection = reduce(vec![ + missing_id.clone(), + missing_id, + unsupported, + invalid, + proposal(), + second_root, + ]); + assert!( + projection + .issues + .contains(&RadrootsTradeReducerIssueV1::MissingMutationId) + ); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::UnsupportedSchema { mutation_id, .. } + if mutation_id == &unsupported_id + ))); + assert!( + projection + .issues + .iter() + .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::InvalidMutation { .. })) + ); + assert!( + projection + .issues + .contains(&RadrootsTradeReducerIssueV1::MultipleRootProposals) + ); + + let foreign_trade = RadrootsTradeId::parse(hex_32('2')).expect("foreign trade"); + let mut input = RadrootsTradeReductionInputV1::new(foreign_trade); + input.mutations = vec![record(proposal())]; + let foreign = reduce_trade_records(input); + assert!(foreign.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::TradeIdentityMismatch { .. } + ))); + } + + #[test] + fn reducer_rejects_invalid_decision_relationships() { + let root = proposal(); + + let missing_proposal = reduce(vec![accepted_decision(&root, '1')]); + assert!( + missing_proposal + .issues + .iter() + .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::MissingProposal { .. })) + ); + + let mut wrong_candidate = accepted_decision(&root, '1'); + if let RadrootsTradeMutationBodyV1::Decision { candidate_id, .. } = + &mut wrong_candidate.body + { + *candidate_id = RadrootsTradeCandidateId::parse(hex_64('f')).expect("candidate id"); + } + let wrong_candidate = recanonicalize(wrong_candidate); + let projection = reduce(vec![root.clone(), wrong_candidate]); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::CandidateIdMismatch { .. } + ))); + + let mut wrong_author = accepted_decision(&root, '2'); + wrong_author.author_pubkey = wrong_author.buyer_pubkey.clone(); + wrong_author.counterparty_pubkey = wrong_author.seller_pubkey.clone(); + let wrong_author = recanonicalize(wrong_author); + let projection = reduce(vec![root.clone(), wrong_author]); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { .. } + ))); + + let mut no_reservation = accepted_decision(&root, '3'); + if let RadrootsTradeMutationBodyV1::Decision { decision, .. } = &mut no_reservation.body { + *decision = RadrootsTradeDecisionV1::Accepted { + reservation_assertion: None, + }; + } + let no_reservation = recanonicalize(no_reservation); + let projection = reduce(vec![root, no_reservation]); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::MissingSellerReservation { .. } + ))); + } + + #[test] + fn reducer_rejects_every_reservation_mismatch() { + let root = proposal(); + let mut mismatched = accepted_decision(&root, '4'); + if let RadrootsTradeMutationBodyV1::Decision { + decision: + RadrootsTradeDecisionV1::Accepted { + reservation_assertion: Some(reservation), + }, + .. + } = &mut mismatched.body + { + reservation.candidate_id = + RadrootsTradeCandidateId::parse(hex_64('f')).expect("candidate id"); + reservation.inventory_authority_id = pubkey('a'); + reservation.commitments[0].unit_code = "kg".to_string(); + } + let mismatched = recanonicalize(mismatched); + let projection = reduce(vec![root.clone(), mismatched]); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::ReservationCandidateMismatch { .. } + ))); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::ReservationAuthorityMismatch { .. } + ))); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. } + ))); + assert_eq!( + projection.conflict_state, + RadrootsTradeConflictStateV1::InventoryAuthorityConflict + ); + + let mut wrong_count = accepted_decision(&root, '5'); + if let RadrootsTradeMutationBodyV1::Decision { + decision: + RadrootsTradeDecisionV1::Accepted { + reservation_assertion: Some(reservation), + }, + .. + } = &mut wrong_count.body + { + let mut extra = reservation.commitments[0].clone(); + extra.line_id = dtag("line-2"); + reservation.commitments.push(extra); + } + let wrong_count = recanonicalize(wrong_count); + let projection = reduce(vec![root, wrong_count]); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. } + ))); + } + + #[test] + fn reducer_covers_decision_conflict_and_ordered_cancellation() { + let root = proposal(); + let accepted = accepted_decision(&root, '1'); + let declined = declined_decision(&root); + let conflicted = reduce(vec![root.clone(), accepted.clone(), declined]); + assert_eq!( + conflicted.conflict_state, + RadrootsTradeConflictStateV1::DecisionConflict + ); + assert!( + conflicted + .issues + .iter() + .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::DecisionConflict { .. })) + ); + + let cancel = cancellation(&root, root_id(&accepted), root_id(&accepted)); + let cancelled = reduce(vec![root, accepted.clone(), cancel]); + assert_eq!( + cancelled.agreement_state, + RadrootsTradeAgreementStateV1::Cancelled + ); + assert_eq!(cancelled.cancelled_claim_ids, vec![root_id(&accepted)]); + } + + #[test] + fn reducer_covers_pre_agreement_cancellation_and_requested_evidence() { + let root = proposal(); + let cancel = candidate_cancellation(&root); + let cancelled = reduce(vec![root.clone(), cancel]); + assert_eq!( + cancelled.agreement_state, + RadrootsTradeAgreementStateV1::Cancelled + ); + + let decision = accepted_decision(&root, '1'); + let mut input = RadrootsTradeReductionInputV1::new(trade_id()); + input.mutations = vec![record(root), record(decision)]; + input.evidence_state = RadrootsTradeEvidenceStateV1::QueryPartial; + assert_eq!( + reduce_trade_records(input).evidence_state, + RadrootsTradeEvidenceStateV1::QueryPartial + ); + } + + #[test] + fn reducer_covers_private_terms_and_attestation_precedence() { + let mut root = proposal(); + if let RadrootsTradeMutationBodyV1::Proposal { candidate } = &mut root.body { + candidate.private_terms = Some(RadrootsTradePrivateTermsRefV1 { + artifact_id: "artifact-1".to_string(), + schema_id: "radroots.private.fulfillment.v1".to_string(), + ciphertext_commitment: hex_64('f'), + required_acknowledgement: true, + }); + } + let root = recanonicalize(root); + let candidate_id = match &root.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => { + candidate.candidate_id.clone().expect("candidate id") + } + _ => unreachable!(), + }; + let decision = accepted_decision(&root, '1'); + for (state, expected) in [ + ( + RadrootsTradePrivateTermsStateV1::AvailableVerified, + RadrootsTradePrivateTermsStateV1::AvailableVerified, + ), + ( + RadrootsTradePrivateTermsStateV1::Undecryptable, + RadrootsTradePrivateTermsStateV1::Undecryptable, + ), + ( + RadrootsTradePrivateTermsStateV1::CommitmentMismatch, + RadrootsTradePrivateTermsStateV1::CommitmentMismatch, + ), + ] { + let mut input = RadrootsTradeReductionInputV1::new(trade_id()); + input.mutations = vec![record(root.clone()), record(decision.clone())]; + input.private_terms = vec![RadrootsTradePrivateTermsEvidenceV1 { + candidate_id: candidate_id.clone(), + state, + }]; + assert_eq!(reduce_trade_records(input).private_terms_state, expected); + } + + for (results, expected) in [ + ( + vec![RadrootsTradeAttestationResultV1::Valid], + RadrootsTradeAttestationStateV1::PresentValid, + ), + ( + vec![ + RadrootsTradeAttestationResultV1::Valid, + RadrootsTradeAttestationResultV1::Invalid, + ], + RadrootsTradeAttestationStateV1::Conflicting, + ), + ] { + let mut input = RadrootsTradeReductionInputV1::new(trade_id()); + input.mutations = vec![record(root.clone()), record(decision.clone())]; + input.attestations = results + .into_iter() + .enumerate() + .map(|(index, result)| RadrootsTradeAttestationRecordV1 { + event_id: event_id(if index == 0 { '8' } else { '9' }), + claim_mutation_id: root_id(&decision), + result, + }) + .collect(); + assert_eq!(reduce_trade_records(input).attestation_state, expected); + } + } + + #[test] + fn reducer_private_helpers_cover_empty_graph_and_conflict_precedence() { + let missing = RadrootsTradeMutationId::parse(hex_64('e')).expect("mutation id"); + assert!(ancestors_of(&missing, &BTreeMap::new(), &mut BTreeMap::new()).is_empty()); + + let mut conflict = RadrootsTradeConflictStateV1::DecisionConflict; + set_conflict( + &mut conflict, + RadrootsTradeConflictStateV1::ConcurrentCandidates, + ); + assert_eq!(conflict, RadrootsTradeConflictStateV1::DecisionConflict); + set_conflict( + &mut conflict, + RadrootsTradeConflictStateV1::InvalidCausalChain, + ); + assert_eq!(conflict, RadrootsTradeConflictStateV1::InvalidCausalChain); + } + + #[test] + fn reducer_rejects_self_identified_decision_author_bypass() { + let root = proposal(); + let mut decision = accepted_decision(&root, '7'); + decision.author_pubkey = decision.buyer_pubkey.clone(); + decision.counterparty_pubkey = decision.buyer_pubkey.clone(); + let decision = recanonicalize(decision); + + let projection = reduce(vec![root, decision]); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { .. } + ))); + } + + #[test] + fn reducer_covers_all_decline_and_counterparty_shapes() { + let root = proposal(); + let first = declined_decision(&root); + let mut second = first.clone(); + second.authored_at_unix_s += 1; + if let RadrootsTradeMutationBodyV1::Decision { + decision: RadrootsTradeDecisionV1::Declined { reason }, + .. + } = &mut second.body + { + *reason = "still unavailable".to_string(); + } + let second = recanonicalize(second); + let projection = reduce(vec![root.clone(), first, second]); + assert_eq!( + projection.negotiation_state, + RadrootsTradeNegotiationStateV1::ClosedDeclined + ); + + let revision = revision_proposal(&root, vec![root_id(&root)]); + let mut revision_decline = declined_decision(&revision); + let RadrootsTradeMutationBodyV1::Decision { + proposal_mutation_id, + candidate_id, + decision, + } = revision_decline.body + else { + unreachable!(); + }; + revision_decline.contract_id = RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID.to_string(); + revision_decline.root_mutation_id = Some(root_id(&root)); + revision_decline.body = RadrootsTradeMutationBodyV1::RevisionDecision { + proposal_mutation_id, + candidate_id: candidate_id.clone(), + decision, + }; + let revision_decline = recanonicalize(revision_decline); + assert_eq!( + declined_candidate_ids(&BTreeMap::from([( + root_id(&revision_decline), + revision_decline, + )])), + vec![candidate_id] + ); + + let candidate = match &root.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(), + _ => unreachable!(), + }; + let mut candidate_record = CandidateRecord { + proposal_mutation_id: root_id(&root), + author_pubkey: root.buyer_pubkey.clone(), + candidate, + }; + let decision = accepted_decision(&root, '8'); + assert_eq!( + candidate_record_author_counterparty(&candidate_record, &decision), + decision.seller_pubkey + ); + candidate_record.author_pubkey = decision.seller_pubkey.clone(); + assert_eq!( + candidate_record_author_counterparty(&candidate_record, &decision), + decision.buyer_pubkey + ); + } + + #[test] + fn reservation_line_validation_checks_each_field() { + let root = proposal(); + let candidate = match &root.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(), + _ => unreachable!(), + }; + let candidate_id = candidate.candidate_id.clone().expect("candidate id"); + let decision_mutation_id = root_id(&accepted_decision(&root, '9')); + + for field in 0..5 { + let mut reservation = reservation(&candidate, '9'); + match field { + 0 => reservation.commitments[0].line_id = dtag("line-other"), + 1 => reservation.commitments[0].bin_id = bin_id("bin-other"), + 2 => reservation.commitments[0].quantity_mantissa = "3".to_string(), + 3 => reservation.commitments[0].quantity_scale = 1, + 4 => reservation.commitments[0].unit_code = "kg".to_string(), + _ => unreachable!(), + } + let mut projection = RadrootsTradeProjectionV1::empty(trade_id()); + assert!(!validate_reservation( + &decision_mutation_id, + &candidate_id, + &candidate, + &reservation, + &mut projection, + )); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. } + ))); + } + } + + #[test] + fn agreement_and_cancellation_helpers_cover_nonterminal_shapes() { + let root = proposal(); + let first_decision = accepted_decision(&root, '1'); + let second_decision = accepted_decision(&root, '2'); + let first_id = root_id(&first_decision); + let second_id = root_id(&second_decision); + let candidate = match &root.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(), + _ => unreachable!(), + }; + let candidate_id = candidate.candidate_id.clone().expect("candidate id"); + let claim = |claim_mutation_id: RadrootsTradeMutationId| RadrootsTradeAgreementClaimV1 { + claim_mutation_id, + proposal_mutation_id: root_id(&root), + candidate_id: candidate_id.clone(), + candidate_author_pubkey: root.buyer_pubkey.clone(), + accepted_by_pubkey: root.seller_pubkey.clone(), + reservation_commitment: hex_64('a'), + }; + let claims = BTreeMap::from([ + (first_id.clone(), claim(first_id.clone())), + (second_id.clone(), claim(second_id.clone())), + ]); + let mutations = BTreeMap::from([ + (first_id.clone(), first_decision), + (second_id.clone(), second_decision), + ]); + let missing_claim = RadrootsTradeMutationId::parse(hex_64('e')).expect("missing claim"); + let cancellations = vec![ + CancellationRecord { + mutation_id: RadrootsTradeMutationId::parse(hex_64('3')).expect("cancellation"), + parent_mutation_ids: Vec::new(), + target_candidate_id: None, + target_claim_mutation_id: None, + }, + CancellationRecord { + mutation_id: RadrootsTradeMutationId::parse(hex_64('4')).expect("cancellation"), + parent_mutation_ids: Vec::new(), + target_candidate_id: None, + target_claim_mutation_id: Some(missing_claim), + }, + CancellationRecord { + mutation_id: RadrootsTradeMutationId::parse(hex_64('5')).expect("cancellation"), + parent_mutation_ids: vec![first_id.clone()], + target_candidate_id: None, + target_claim_mutation_id: Some(first_id.clone()), + }, + ]; + let mut projection = RadrootsTradeProjectionV1::empty(trade_id()); + apply_agreement_state( + &mut projection, + &claims, + &mutations, + &BTreeMap::new(), + &cancellations, + ); + assert_eq!( + projection.agreement_state, + RadrootsTradeAgreementStateV1::Agreed + ); + assert_eq!(projection.cancelled_claim_ids, vec![first_id]); + + let mut incompatible_claims = claims.clone(); + incompatible_claims + .get_mut(&second_id) + .expect("second claim") + .candidate_id = RadrootsTradeCandidateId::parse(hex_64('f')).expect("candidate"); + let mut incompatible = RadrootsTradeProjectionV1::empty(trade_id()); + apply_agreement_state( + &mut incompatible, + &incompatible_claims, + &mutations, + &BTreeMap::new(), + &[], + ); + assert_eq!( + incompatible.conflict_state, + RadrootsTradeConflictStateV1::DecisionConflict + ); + + let candidates = BTreeMap::from([( + root_id(&root), + CandidateRecord { + proposal_mutation_id: root_id(&root), + author_pubkey: root.author_pubkey.clone(), + candidate: candidate.clone(), + }, + )]); + let unknown_candidate = RadrootsTradeCandidateId::parse(hex_64('f')).expect("candidate"); + assert!(!cancellation_without_claim( + &[CancellationRecord { + mutation_id: RadrootsTradeMutationId::parse(hex_64('6')).expect("cancellation"), + parent_mutation_ids: Vec::new(), + target_candidate_id: Some(unknown_candidate), + target_claim_mutation_id: None, + }], + &candidates, + )); + let mut disabled_candidate = candidate; + disabled_candidate.cancellation.buyer_pre_agreement = false; + let disabled_candidates = BTreeMap::from([( + root_id(&root), + CandidateRecord { + proposal_mutation_id: root_id(&root), + author_pubkey: root.author_pubkey.clone(), + candidate: disabled_candidate, + }, + )]); + assert!(!cancellation_without_claim( + &[CancellationRecord { + mutation_id: RadrootsTradeMutationId::parse(hex_64('7')).expect("cancellation"), + parent_mutation_ids: Vec::new(), + target_candidate_id: Some(candidate_id), + target_claim_mutation_id: None, + }], + &disabled_candidates, + )); + } + + #[test] + fn evidence_state_covers_missing_proposal_independently() { + let root = proposal(); + let mut projection = RadrootsTradeProjectionV1::empty(trade_id()); + projection.root_mutation_id = Some(root_id(&root)); + projection + .missing_proposal_ids + .push(RadrootsTradeMutationId::parse(hex_64('e')).expect("proposal")); + assert_eq!( + reduce_evidence_state(&projection, RadrootsTradeEvidenceStateV1::Complete), + RadrootsTradeEvidenceStateV1::Missing + ); + } }