commit d97ff0b2cfce3420d92ca480e701b19b3a7bc04a
parent eec737f2f8c7ebe2271a8c567be29cc77ec9c9ae
Author: triesap <tyson@radroots.org>
Date: Thu, 30 Jul 2026 08:09:15 +0000
trade: stabilize deterministic reducer semantics
- exclude unsupported records from supported domain projections
- reduce duplicate evidence and attestations deterministically
- report concurrent roots and digest failures without hidden winners
- pin permutation properties and canonical projection vectors
Diffstat:
2 files changed, 224 insertions(+), 15 deletions(-)
diff --git a/contracts/conformance/vectors/trade/reduce_records.v1.json b/contracts/conformance/vectors/trade/reduce_records.v1.json
@@ -16,7 +16,7 @@
"evidence_state": "complete",
"conflict_state": "none",
"attestation_state": "none",
- "projection_digest": "input_order_independent_sha256_over_canonical_projection"
+ "projection_digest": "35a5f555344febe675f4e5e6b15865356400b2b8f7791fc631590e0f1e1fd441"
}
},
{
@@ -62,6 +62,44 @@
"agreement_claim_visibility": "retained_when_local_claim_is_known",
"global_invalidity": false
}
+ },
+ {
+ "id": "trade_reduce_unsupported_version_isolated_005",
+ "kind": "trade.reduce_records",
+ "input": {
+ "shape": "unsupported_version_proposal_without_supported_records"
+ },
+ "expected": {
+ "evidence_state": "unsupported_version",
+ "negotiation_state": "none",
+ "agreement_state": "none",
+ "unsupported_record_domain_authority": false
+ }
+ },
+ {
+ "id": "trade_reduce_private_evidence_precedence_006",
+ "kind": "trade.reduce_records",
+ "input": {
+ "shape": "agreed_private_terms_candidate_with_conflicting_duplicate_evidence",
+ "evidence_order": "arbitrary"
+ },
+ "expected": {
+ "private_terms_state": "commitment_mismatch",
+ "projection_digest": "input_order_independent"
+ }
+ },
+ {
+ "id": "trade_reduce_attestation_deduplication_007",
+ "kind": "trade.reduce_records",
+ "input": {
+ "shape": "agreed_claim_with_duplicate_valid_and_distinct_invalid_attestations",
+ "attestation_order": "arbitrary"
+ },
+ "expected": {
+ "attestation_state": "conflicting",
+ "duplicate_records": "removed",
+ "projection_digest": "input_order_independent"
+ }
}
]
}
diff --git a/crates/trade/src/workflow.rs b/crates/trade/src/workflow.rs
@@ -1,5 +1,7 @@
#![forbid(unsafe_code)]
+#[cfg(all(not(feature = "std"), feature = "serde_json"))]
+use alloc::format;
#[cfg(not(feature = "std"))]
use alloc::{
collections::{BTreeMap, BTreeSet},
@@ -281,7 +283,7 @@ pub enum RadrootsTradeAttestationResultV1 {
#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
#[cfg_attr(feature = "dto-bindgen", dto(export))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct RadrootsTradeAttestationRecordV1 {
#[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
event_id: EventId,
@@ -417,8 +419,18 @@ impl RadrootsTradeProjectionV1 {
.sort_by_key(|left| left.claim_mutation_id);
self.issues.sort();
self.issues.dedup();
- self.attestations.sort_by_key(|left| left.event_id);
- self.projection_digest = projection_digest(self);
+ self.attestations.sort();
+ self.attestations.dedup();
+ match projection_digest(self) {
+ Ok(digest) => self.projection_digest = digest,
+ Err(reason) => {
+ self.projection_digest.clear();
+ self.issues
+ .push(RadrootsTradeReducerIssueV1::ProjectionDigestUnavailable { reason });
+ self.issues.sort();
+ self.issues.dedup();
+ }
+ }
}
pub fn reducer_contract_id(&self) -> &str {
@@ -710,7 +722,6 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra
mutation_id,
schema_version: record.mutation.schema_version,
});
- mutations.entry(mutation_id).or_insert(record.mutation);
continue;
}
if let Err(error) = record.mutation.validate() {
@@ -822,6 +833,10 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra
projection
.issues
.push(RadrootsTradeReducerIssueV1::MultipleRootProposals);
+ set_conflict(
+ &mut projection.conflict_state,
+ RadrootsTradeConflictStateV1::ConcurrentCandidates,
+ );
} else {
projection.root_mutation_id = root_proposals.first().cloned();
let root = projection
@@ -1225,10 +1240,14 @@ fn reduce_private_terms_state(
candidates_by_proposal: &BTreeMap<MutationId, CandidateRecord>,
private_terms: &[RadrootsTradePrivateTermsEvidenceV1],
) -> RadrootsTradePrivateTermsStateV1 {
- let private_terms_by_candidate = private_terms
- .iter()
- .map(|record| (record.candidate_id, record.state))
- .collect::<BTreeMap<_, _>>();
+ let mut private_terms_by_candidate =
+ BTreeMap::<CandidateId, RadrootsTradePrivateTermsStateV1>::new();
+ for record in private_terms {
+ private_terms_by_candidate
+ .entry(record.candidate_id)
+ .and_modify(|state| *state = (*state).max(record.state))
+ .or_insert(record.state);
+ }
let mut required_states = Vec::new();
for claim in &projection.agreement_claims {
let candidate_record = candidates_by_proposal
@@ -1340,22 +1359,22 @@ fn set_conflict(
}
#[cfg(feature = "serde_json")]
-fn projection_digest(projection: &RadrootsTradeProjectionV1) -> String {
+fn projection_digest(projection: &RadrootsTradeProjectionV1) -> Result<String, String> {
let mut digest_input = projection.clone();
digest_input.projection_digest.clear();
let value = serde_json::to_value(&digest_input)
- .expect("trade projection contains only serializable contract values");
+ .map_err(|error| format!("projection serialization failed: {error}"))?;
let canonical = radroots_event::trade::canonical_jcs_value(&value)
- .expect("serialized trade projection must be canonicalizable");
+ .map_err(|error| format!("projection canonicalization failed: {error}"))?;
let mut hasher = Sha256::new();
hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN);
hasher.update(canonical.as_bytes());
- hex::encode(hasher.finalize())
+ Ok(hex::encode(hasher.finalize()))
}
#[cfg(not(feature = "serde_json"))]
-fn projection_digest(_projection: &RadrootsTradeProjectionV1) -> String {
- String::new()
+fn projection_digest(_projection: &RadrootsTradeProjectionV1) -> Result<String, String> {
+ Err("projection digest requires the serde_json feature".to_string())
}
#[cfg(test)]
@@ -1375,6 +1394,9 @@ mod tests {
};
use radroots_test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_BOB_PUBLIC_KEY_HEX};
+ const REDUCER_VECTORS: &str =
+ include_str!("../../../contracts/conformance/vectors/trade/reduce_records.v1.json");
+
fn hex_64(character: char) -> String {
core::iter::repeat_n(character, 64).collect()
}
@@ -1736,11 +1758,156 @@ mod tests {
let second = reduce(vec![decision, proposal]);
assert_eq!(first.agreement_state, RadrootsTradeAgreementStateV1::Agreed);
+ assert_eq!(
+ first.projection_digest,
+ "35a5f555344febe675f4e5e6b15865356400b2b8f7791fc631590e0f1e1fd441"
+ );
assert_eq!(first.projection_digest, second.projection_digest);
assert_eq!(first.active_agreement_claim_ids.len(), 1);
}
#[test]
+ fn reducer_projection_is_identical_for_every_three_record_permutation() {
+ let proposal = proposal();
+ let decision = accepted_decision(&proposal, '1');
+ let cancellation = cancellation(&proposal, root_id(&decision), root_id(&decision));
+ let permutations = [
+ vec![proposal.clone(), decision.clone(), cancellation.clone()],
+ vec![proposal.clone(), cancellation.clone(), decision.clone()],
+ vec![decision.clone(), proposal.clone(), cancellation.clone()],
+ vec![decision.clone(), cancellation.clone(), proposal.clone()],
+ vec![cancellation.clone(), proposal.clone(), decision.clone()],
+ vec![cancellation, decision, proposal],
+ ];
+ let expected = reduce(permutations[0].clone());
+
+ for permutation in permutations.into_iter().skip(1) {
+ assert_eq!(reduce(permutation), expected);
+ }
+ }
+
+ #[test]
+ fn reducer_excludes_unsupported_versions_from_domain_semantics() {
+ let mut unsupported = proposal();
+ unsupported.schema_version += 1;
+ let unsupported_id = root_id(&unsupported);
+
+ let projection = reduce(vec![unsupported]);
+
+ assert_eq!(
+ projection.evidence_state,
+ RadrootsTradeEvidenceStateV1::UnsupportedVersion
+ );
+ assert_eq!(
+ projection.negotiation_state,
+ RadrootsTradeNegotiationStateV1::None
+ );
+ assert_eq!(
+ projection.agreement_state,
+ RadrootsTradeAgreementStateV1::None
+ );
+ assert_eq!(projection.root_mutation_id, None);
+ assert_eq!(projection.unsupported_mutation_ids, vec![unsupported_id]);
+ }
+
+ #[test]
+ fn reducer_private_evidence_precedence_is_permutation_independent() {
+ let mut root = proposal();
+ if let TradeMutationBodyV1::Proposal { candidate } = &mut root.body {
+ candidate.private_terms = Some(TradePrivateTermsRefV1 {
+ artifact_id: "artifact-1".to_string(),
+ schema_id: "radroots.private.fulfillment.v1".to_string(),
+ ciphertext_commitment: hex_64('f'),
+ required_acknowledgement: true,
+ });
+ }
+ let root = recanonicalize(root);
+ let candidate_id = match &root.body {
+ TradeMutationBodyV1::Proposal { candidate } => candidate.candidate_id.unwrap(),
+ _ => unreachable!(),
+ };
+ let decision = accepted_decision(&root, '1');
+ let evidence = |state| RadrootsTradePrivateTermsEvidenceV1::new(candidate_id, state);
+ let reduce_with = |private_terms| {
+ RadrootsTradeReductionInputV1::new(trade_id())
+ .with_mutations(vec![record(root.clone()), record(decision.clone())])
+ .with_private_terms(private_terms)
+ };
+
+ let first = reduce_trade_records(reduce_with(vec![
+ evidence(RadrootsTradePrivateTermsStateV1::AvailableVerified),
+ evidence(RadrootsTradePrivateTermsStateV1::CommitmentMismatch),
+ ]));
+ let second = reduce_trade_records(reduce_with(vec![
+ evidence(RadrootsTradePrivateTermsStateV1::CommitmentMismatch),
+ evidence(RadrootsTradePrivateTermsStateV1::AvailableVerified),
+ ]));
+
+ assert_eq!(
+ first.private_terms_state,
+ RadrootsTradePrivateTermsStateV1::CommitmentMismatch
+ );
+ assert_eq!(first, second);
+ }
+
+ #[test]
+ fn reducer_attestation_order_and_duplicates_do_not_change_digest() {
+ let root = proposal();
+ let decision = accepted_decision(&root, '1');
+ let valid = RadrootsTradeAttestationRecordV1::new(
+ event_id('8'),
+ root_id(&decision),
+ RadrootsTradeAttestationResultV1::Valid,
+ );
+ let invalid = RadrootsTradeAttestationRecordV1::new(
+ event_id('9'),
+ root_id(&decision),
+ RadrootsTradeAttestationResultV1::Invalid,
+ );
+ let reduce_with = |attestations| {
+ reduce_trade_records(
+ RadrootsTradeReductionInputV1::new(trade_id())
+ .with_mutations(vec![record(root.clone()), record(decision.clone())])
+ .with_attestations(attestations),
+ )
+ };
+
+ let first = reduce_with(vec![valid.clone(), invalid.clone(), valid.clone()]);
+ let second = reduce_with(vec![invalid, valid]);
+
+ assert_eq!(
+ first.attestation_state,
+ RadrootsTradeAttestationStateV1::Conflicting
+ );
+ assert_eq!(first.attestations.len(), 2);
+ assert_eq!(first, second);
+ }
+
+ #[test]
+ fn reducer_conformance_inventory_covers_deterministic_edge_cases() {
+ let document: serde_json::Value =
+ serde_json::from_str(REDUCER_VECTORS).expect("reducer vectors parse");
+ let ids = document["vectors"]
+ .as_array()
+ .expect("reducer vector array")
+ .iter()
+ .map(|vector| vector["id"].as_str().expect("reducer vector id"))
+ .collect::<BTreeSet<_>>();
+
+ for required in [
+ "trade_reduce_agreed_projection_digest_001",
+ "trade_reduce_contested_claims_002",
+ "trade_reduce_attestation_only_003",
+ "trade_reduce_missing_parent_004",
+ "trade_reduce_unsupported_version_isolated_005",
+ "trade_reduce_private_evidence_precedence_006",
+ "trade_reduce_attestation_deduplication_007",
+ ] {
+ assert!(ids.contains(required), "missing reducer vector {required}");
+ }
+ }
+
+ #[test]
fn reducer_preserves_contested_incompatible_acceptances_without_timestamp_winner() {
let proposal = proposal();
let first = accepted_decision(&proposal, '1');
@@ -1967,6 +2134,10 @@ mod tests {
.issues
.contains(&RadrootsTradeReducerIssueV1::MultipleRootProposals)
);
+ assert_eq!(
+ projection.conflict_state,
+ RadrootsTradeConflictStateV1::ConcurrentCandidates
+ );
let foreign_trade = TradeId::parse(hex_32('2')).expect("foreign trade");
let mut input = RadrootsTradeReductionInputV1::new(foreign_trade);