rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

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 }