trade_agreement_attestation.rs (15778B)
1 #![forbid(unsafe_code)] 2 #![cfg_attr(coverage_nightly, coverage(off))] 3 4 use radroots_event::envelope::kind::TRADE_MUTATION_EVENT_KINDS; 5 use radroots_event::id::{AddressableCoordinate, EventId, MutationId}; 6 use radroots_event::trade::canonical_jcs_value; 7 use radroots_trade::evidence::{RadrootsTradeAttestationResultV1, RadrootsTradeEvidenceStateV1}; 8 use radroots_trade::model::{ 9 RadrootsTradeAgreementStateV1, RadrootsTradeAttestationStateV1, RadrootsTradeProjectionV1, 10 }; 11 use radroots_trade::reducer::{RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION}; 12 use serde::{Deserialize, Serialize}; 13 use sha2::{Digest, Sha256}; 14 use thiserror::Error; 15 16 pub const RHI_AGREEMENT_ATTESTATION_PROTOCOL_ID: &str = "radroots.rhi.agreement_attestation.v1"; 17 pub const RHI_AGREEMENT_ATTESTATION_REPORT_VERSION: u16 = 1; 18 pub const RHI_AGREEMENT_ATTESTATION_PROOF_SYSTEM_LOCAL_STATEMENT_HASH: &str = 19 "local_statement_hash"; 20 21 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] 22 #[serde(rename_all = "snake_case")] 23 pub enum TradeAgreementAttestationBackend { 24 #[default] 25 LocalStatementHash, 26 } 27 28 impl TradeAgreementAttestationBackend { 29 pub const fn as_str(self) -> &'static str { 30 match self { 31 Self::LocalStatementHash => "local_statement_hash", 32 } 33 } 34 } 35 36 #[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] 37 #[serde(deny_unknown_fields)] 38 pub struct TradeAgreementAttestationPolicy { 39 #[serde(default)] 40 pub backend: TradeAgreementAttestationBackend, 41 #[serde(default)] 42 pub validator_set_addr: Option<String>, 43 #[serde(default)] 44 pub validator_set_event_id: Option<String>, 45 #[serde(default)] 46 pub expected_statement_contract_hash: Option<String>, 47 } 48 49 impl core::fmt::Debug for TradeAgreementAttestationPolicy { 50 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 51 formatter.write_str("TradeAgreementAttestationPolicy([redacted])") 52 } 53 } 54 55 impl TradeAgreementAttestationPolicy { 56 pub fn validate(&self) -> Result<(), TradeAgreementAttestationError> { 57 validate_optional_hash32(&self.expected_statement_contract_hash)?; 58 match ( 59 self.validator_set_addr.as_deref(), 60 self.validator_set_event_id.as_deref(), 61 ) { 62 (Some(addr), Some(event_id)) => { 63 AddressableCoordinate::parse(addr).map_err(|_| { 64 TradeAgreementAttestationError::new( 65 TradeAgreementAttestationErrorKind::InvalidValidatorSetBinding, 66 ) 67 })?; 68 EventId::parse(event_id).map_err(|_| { 69 TradeAgreementAttestationError::new( 70 TradeAgreementAttestationErrorKind::InvalidValidatorSetBinding, 71 ) 72 })?; 73 Ok(()) 74 } 75 (None, None) => Ok(()), 76 (Some(_), None) | (None, Some(_)) => Err(TradeAgreementAttestationError::new( 77 TradeAgreementAttestationErrorKind::MissingValidatorSetBinding, 78 )), 79 } 80 } 81 82 fn validator_set_binding( 83 &self, 84 ) -> Result<Option<TradeAgreementAttestationValidatorSetBinding>, TradeAgreementAttestationError> 85 { 86 self.validate()?; 87 match ( 88 self.validator_set_addr.as_deref(), 89 self.validator_set_event_id.as_deref(), 90 ) { 91 (Some(addr), Some(event_id)) => { 92 Ok(Some(TradeAgreementAttestationValidatorSetBinding { 93 validator_set_addr: addr.to_owned(), 94 validator_set_event_id: event_id.to_owned(), 95 })) 96 } 97 _ => Ok(None), 98 } 99 } 100 } 101 102 #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] 103 #[serde(deny_unknown_fields)] 104 pub struct TradeAgreementAttestationValidatorSetBinding { 105 pub validator_set_addr: String, 106 pub validator_set_event_id: String, 107 } 108 109 impl core::fmt::Debug for TradeAgreementAttestationValidatorSetBinding { 110 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 111 formatter.write_str("TradeAgreementAttestationValidatorSetBinding([redacted])") 112 } 113 } 114 115 #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] 116 #[serde(deny_unknown_fields)] 117 pub struct TradeAgreementAttestationStatementV1 { 118 pub protocol_id: String, 119 pub schema_version: u16, 120 pub reducer_contract_id: String, 121 pub reducer_version: u16, 122 pub trade_id: String, 123 pub claim_mutation_id: String, 124 pub projection_digest: String, 125 pub agreement_state: RadrootsTradeAgreementStateV1, 126 pub attestation_state_before_report: RadrootsTradeAttestationStateV1, 127 pub active_agreement_claim_ids: Vec<String>, 128 pub contested_claim_ids: Vec<String>, 129 pub cancelled_claim_ids: Vec<String>, 130 pub evidence_state: RadrootsTradeEvidenceStateV1, 131 #[serde(skip_serializing_if = "Option::is_none")] 132 pub validator_set: Option<TradeAgreementAttestationValidatorSetBinding>, 133 } 134 135 impl core::fmt::Debug for TradeAgreementAttestationStatementV1 { 136 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 137 formatter.write_str("TradeAgreementAttestationStatementV1([redacted])") 138 } 139 } 140 141 #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] 142 #[serde(deny_unknown_fields)] 143 pub struct TradeAgreementAttestationReportV1 { 144 pub report_version: u16, 145 pub attestation_id: String, 146 pub result: RadrootsTradeAttestationResultV1, 147 pub statement: TradeAgreementAttestationStatementV1, 148 pub statement_hash: String, 149 pub proof_system: String, 150 pub proof_identity_hash: String, 151 } 152 153 impl core::fmt::Debug for TradeAgreementAttestationReportV1 { 154 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 155 formatter.write_str("TradeAgreementAttestationReportV1([redacted])") 156 } 157 } 158 159 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 160 pub enum TradeAgreementAttestationErrorKind { 161 MissingAgreementClaim, 162 MissingValidatorSetBinding, 163 InvalidValidatorSetBinding, 164 InvalidHashField, 165 TradeProtocol, 166 Encoding, 167 } 168 169 impl TradeAgreementAttestationErrorKind { 170 #[must_use] 171 pub const fn code(self) -> &'static str { 172 match self { 173 Self::MissingAgreementClaim => "agreement_claim_missing", 174 Self::MissingValidatorSetBinding => "validator_set_binding_missing", 175 Self::InvalidValidatorSetBinding => "validator_set_binding_invalid", 176 Self::InvalidHashField => "configured_hash_invalid", 177 Self::TradeProtocol => "trade_protocol_invalid", 178 Self::Encoding => "attestation_encoding_failed", 179 } 180 } 181 } 182 183 impl core::fmt::Display for TradeAgreementAttestationErrorKind { 184 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 185 formatter.write_str(match self { 186 Self::MissingAgreementClaim => "agreement claim is missing", 187 Self::MissingValidatorSetBinding => "attestation policy is incomplete", 188 Self::InvalidValidatorSetBinding => "attestation policy is invalid", 189 Self::InvalidHashField => "configured hash field is invalid", 190 Self::TradeProtocol => "trade protocol input is invalid", 191 Self::Encoding => "attestation encoding failed", 192 }) 193 } 194 } 195 196 #[derive(Clone, Copy, PartialEq, Eq, Error)] 197 #[error("{kind}")] 198 pub struct TradeAgreementAttestationError { 199 kind: TradeAgreementAttestationErrorKind, 200 } 201 202 impl TradeAgreementAttestationError { 203 const fn new(kind: TradeAgreementAttestationErrorKind) -> Self { 204 Self { kind } 205 } 206 207 #[must_use] 208 pub const fn kind(self) -> TradeAgreementAttestationErrorKind { 209 self.kind 210 } 211 212 #[must_use] 213 pub const fn code(self) -> &'static str { 214 self.kind.code() 215 } 216 } 217 218 impl core::fmt::Debug for TradeAgreementAttestationError { 219 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 220 formatter 221 .debug_struct("TradeAgreementAttestationError") 222 .field("kind", &self.kind) 223 .finish() 224 } 225 } 226 227 pub fn attest_projection_claim( 228 projection: &RadrootsTradeProjectionV1, 229 claim_mutation_id: &MutationId, 230 policy: &TradeAgreementAttestationPolicy, 231 ) -> Result<TradeAgreementAttestationReportV1, TradeAgreementAttestationError> { 232 policy.validate()?; 233 if !projection 234 .agreement_claims() 235 .iter() 236 .any(|claim| claim.claim_mutation_id() == claim_mutation_id) 237 { 238 return Err(TradeAgreementAttestationError::new( 239 TradeAgreementAttestationErrorKind::MissingAgreementClaim, 240 )); 241 } 242 let statement = TradeAgreementAttestationStatementV1 { 243 protocol_id: RHI_AGREEMENT_ATTESTATION_PROTOCOL_ID.to_owned(), 244 schema_version: RHI_AGREEMENT_ATTESTATION_REPORT_VERSION, 245 reducer_contract_id: RADROOTS_TRADE_REDUCER_CONTRACT_ID.to_owned(), 246 reducer_version: RADROOTS_TRADE_REDUCER_VERSION, 247 trade_id: projection.trade_id().to_hex(), 248 claim_mutation_id: claim_mutation_id.to_hex(), 249 projection_digest: projection.projection_digest().to_owned(), 250 agreement_state: projection.agreement_state(), 251 attestation_state_before_report: projection.attestation_state(), 252 active_agreement_claim_ids: mutation_ids_to_strings( 253 projection.active_agreement_claim_ids(), 254 ), 255 contested_claim_ids: mutation_ids_to_strings(projection.contested_claim_ids()), 256 cancelled_claim_ids: mutation_ids_to_strings(projection.cancelled_claim_ids()), 257 evidence_state: projection.evidence_state(), 258 validator_set: policy.validator_set_binding()?, 259 }; 260 let statement_hash = hash_canonical_value( 261 b"radroots:rhi-agreement-attestation-statement:v1\0", 262 &statement, 263 )?; 264 let result = if projection.agreement_state() == RadrootsTradeAgreementStateV1::Agreed 265 && projection 266 .active_agreement_claim_ids() 267 .iter() 268 .any(|claim| claim == claim_mutation_id) 269 && !projection 270 .contested_claim_ids() 271 .iter() 272 .any(|claim| claim == claim_mutation_id) 273 && !projection 274 .cancelled_claim_ids() 275 .iter() 276 .any(|claim| claim == claim_mutation_id) 277 { 278 RadrootsTradeAttestationResultV1::Valid 279 } else { 280 RadrootsTradeAttestationResultV1::Invalid 281 }; 282 let proof_identity_hash = hash_canonical_value( 283 b"radroots:rhi-agreement-attestation-proof-identity:v1\0", 284 &serde_json::json!({ 285 "backend": policy.backend.as_str(), 286 "proof_system": RHI_AGREEMENT_ATTESTATION_PROOF_SYSTEM_LOCAL_STATEMENT_HASH, 287 "statement_hash": statement_hash, 288 "validator_set": statement.validator_set.clone(), 289 }), 290 )?; 291 let attestation_id = hash_canonical_value( 292 b"radroots:rhi-agreement-attestation-report:v1\0", 293 &serde_json::json!({ 294 "proof_identity_hash": proof_identity_hash, 295 "result": result, 296 "statement_hash": statement_hash, 297 }), 298 )?; 299 Ok(TradeAgreementAttestationReportV1 { 300 report_version: RHI_AGREEMENT_ATTESTATION_REPORT_VERSION, 301 attestation_id, 302 result, 303 statement, 304 statement_hash, 305 proof_system: RHI_AGREEMENT_ATTESTATION_PROOF_SYSTEM_LOCAL_STATEMENT_HASH.to_owned(), 306 proof_identity_hash, 307 }) 308 } 309 310 pub fn trade_mutation_subscription_kinds() -> Vec<u32> { 311 TRADE_MUTATION_EVENT_KINDS.to_vec() 312 } 313 314 fn mutation_ids_to_strings(values: &[MutationId]) -> Vec<String> { 315 values.iter().map(MutationId::to_hex).collect() 316 } 317 318 fn validate_optional_hash32(value: &Option<String>) -> Result<(), TradeAgreementAttestationError> { 319 if let Some(value) = value { 320 validate_hash32(value)?; 321 } 322 Ok(()) 323 } 324 325 fn validate_hash32(value: &str) -> Result<(), TradeAgreementAttestationError> { 326 let stripped = value.strip_prefix("0x").unwrap_or(value); 327 if stripped.len() != 64 || !stripped.bytes().all(|byte| byte.is_ascii_hexdigit()) { 328 return Err(TradeAgreementAttestationError::new( 329 TradeAgreementAttestationErrorKind::InvalidHashField, 330 )); 331 } 332 Ok(()) 333 } 334 335 fn hash_canonical_value( 336 domain: &[u8], 337 value: &impl Serialize, 338 ) -> Result<String, TradeAgreementAttestationError> { 339 let value = serde_json::to_value(value).map_err(|_| { 340 TradeAgreementAttestationError::new(TradeAgreementAttestationErrorKind::Encoding) 341 })?; 342 let canonical = canonical_jcs_value(&value).map_err(|_| { 343 TradeAgreementAttestationError::new(TradeAgreementAttestationErrorKind::TradeProtocol) 344 })?; 345 let mut hasher = Sha256::new(); 346 hasher.update(domain); 347 hasher.update(canonical.as_bytes()); 348 Ok(format!("{:x}", hasher.finalize())) 349 } 350 351 #[cfg(test)] 352 mod tests { 353 use super::*; 354 355 #[test] 356 fn public_failures_are_closed_source_free_and_redacted() { 357 for kind in [ 358 TradeAgreementAttestationErrorKind::MissingAgreementClaim, 359 TradeAgreementAttestationErrorKind::MissingValidatorSetBinding, 360 TradeAgreementAttestationErrorKind::InvalidValidatorSetBinding, 361 TradeAgreementAttestationErrorKind::InvalidHashField, 362 TradeAgreementAttestationErrorKind::TradeProtocol, 363 TradeAgreementAttestationErrorKind::Encoding, 364 ] { 365 let error = TradeAgreementAttestationError::new(kind); 366 assert_eq!(error.kind(), kind); 367 assert!(!error.code().is_empty()); 368 assert!(std::error::Error::source(&error).is_none()); 369 let rendered = format!("{error} {error:?}"); 370 for forbidden in [ 371 "validator_set_addr", 372 "validator_set_event_id", 373 "serde_json", 374 "TradeProtocolError", 375 "/tmp/", 376 ] { 377 assert!(!rendered.contains(forbidden)); 378 } 379 } 380 } 381 382 #[test] 383 fn policy_failures_discard_field_values_and_dependency_causes() { 384 let missing_policy = TradeAgreementAttestationPolicy { 385 validator_set_addr: Some("secret:coordinate".to_owned()), 386 ..TradeAgreementAttestationPolicy::default() 387 }; 388 assert_eq!( 389 format!("{missing_policy:?}"), 390 "TradeAgreementAttestationPolicy([redacted])" 391 ); 392 let missing = missing_policy.validate().expect_err("partial binding"); 393 assert_eq!( 394 missing.kind(), 395 TradeAgreementAttestationErrorKind::MissingValidatorSetBinding 396 ); 397 398 let invalid = TradeAgreementAttestationPolicy { 399 validator_set_addr: Some("secret:coordinate".to_owned()), 400 validator_set_event_id: Some("secret:event".to_owned()), 401 ..TradeAgreementAttestationPolicy::default() 402 } 403 .validate() 404 .expect_err("invalid binding"); 405 assert_eq!( 406 invalid.kind(), 407 TradeAgreementAttestationErrorKind::InvalidValidatorSetBinding 408 ); 409 410 let hash = TradeAgreementAttestationPolicy { 411 expected_statement_contract_hash: Some("secret:hash".to_owned()), 412 ..TradeAgreementAttestationPolicy::default() 413 } 414 .validate() 415 .expect_err("invalid hash"); 416 assert_eq!( 417 hash.kind(), 418 TradeAgreementAttestationErrorKind::InvalidHashField 419 ); 420 421 let rendered = format!("{missing:?} {invalid:?} {hash:?}"); 422 assert!(!rendered.contains("secret")); 423 assert!(!rendered.contains("coordinate")); 424 assert!(!rendered.contains("event")); 425 assert!(!rendered.contains("hash")); 426 } 427 }