Skip to main content

dpp_calc/kernel/
receipt.rs

1//! Proof-of-calculation receipt — auditable envelope for every calculator result.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use super::error::CalcError;
8
9// Re-export the JCS hashing helpers so callers keep using `receipt::jcs_hash` /
10// `receipt::input_hash` — they are split into `hashing.rs` for readability but
11// belong to the same proof-of-calculation surface.
12pub use super::hashing::{input_hash, jcs_hash};
13
14/// Proof-of-calculation envelope emitted by every calculator function.
15///
16/// Carries enough information to reproduce or audit the result: both inputs
17/// and numeric outputs are JCS-hashed (RFC 8785) so an auditor can verify the
18/// same inputs produce the same outputs, and the exact ruleset + factor dataset
19/// versions are recorded. The receipt may be signed by the vault via
20/// [`seal_with_jws`](CalculationReceipt::seal_with_jws) after calling
21/// [`canonical_bytes_for_signing`](CalculationReceipt::canonical_bytes_for_signing).
22///
23/// Intended to be stored alongside the computed value in the proof-bound store.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct CalculationReceipt {
26    /// Unique receipt identifier (UUIDv7, time-sortable).
27    pub receipt_id: Uuid,
28    /// SHA-256 of the JCS (RFC 8785) canonical JSON of the calculator inputs.
29    pub input_hash: String,
30    /// SHA-256 of the JCS (RFC 8785) canonical JSON of the numeric output values.
31    /// Empty string until populated via [`with_output_hash`](CalculationReceipt::with_output_hash).
32    pub output_hash: String,
33    /// Machine-readable identifier of the ruleset used.
34    pub ruleset_id: String,
35    /// Version of the ruleset (semver-shaped string).
36    pub ruleset_version: String,
37    /// Identifier of the factor dataset (empty if no factor provider was used).
38    pub factor_dataset_id: String,
39    /// Version of the factor dataset (empty if no factor provider was used).
40    pub factor_dataset_version: String,
41    /// SHA-256 of the full factor table at calculation time.
42    /// `None` when the calculation did not use a `FactorProvider`.
43    pub factor_set_hash: Option<String>,
44    /// UTC timestamp when the calculation ran.
45    pub computed_at: DateTime<Utc>,
46    /// JWS signature produced by the vault/engine after calculation.
47    /// `None` until the caller calls [`seal_with_jws`](CalculationReceipt::seal_with_jws).
48    pub jws: Option<String>,
49}
50
51impl CalculationReceipt {
52    pub fn new(
53        input_hash: impl Into<String>,
54        ruleset_id: impl Into<String>,
55        ruleset_version: impl Into<String>,
56    ) -> Self {
57        Self {
58            receipt_id: Uuid::now_v7(),
59            input_hash: input_hash.into(),
60            output_hash: String::new(),
61            ruleset_id: ruleset_id.into(),
62            ruleset_version: ruleset_version.into(),
63            factor_dataset_id: String::new(),
64            factor_dataset_version: String::new(),
65            factor_set_hash: None,
66            computed_at: Utc::now(),
67            jws: None,
68        }
69    }
70
71    /// Bind the numeric output values to this receipt.
72    pub fn with_output_hash(mut self, hash: impl Into<String>) -> Self {
73        self.output_hash = hash.into();
74        self
75    }
76
77    /// Attach factor-provider provenance to this receipt.
78    pub fn with_factor_provider(mut self, provider: &dyn super::factor::FactorProvider) -> Self {
79        self.factor_dataset_id = provider.dataset_id().to_owned();
80        self.factor_dataset_version = provider.dataset_version().to_owned();
81        self.factor_set_hash = Some(provider.table_hash().to_owned());
82        self
83    }
84
85    /// Attach a JWS signature produced by the external signing infrastructure.
86    /// Call after [`canonical_bytes_for_signing`](CalculationReceipt::canonical_bytes_for_signing)
87    /// to avoid signing the jws field itself.
88    pub fn seal_with_jws(mut self, jws: String) -> Self {
89        self.jws = Some(jws);
90        self
91    }
92
93    /// JCS-canonical bytes of this receipt without the `jws` field.
94    ///
95    /// Pass these bytes to the vault's signing infrastructure, then call
96    /// [`seal_with_jws`](CalculationReceipt::seal_with_jws) with the resulting
97    /// JWS to produce the final sealed receipt.
98    pub fn canonical_bytes_for_signing(&self) -> Result<Vec<u8>, CalcError> {
99        let mut v =
100            serde_json::to_value(self).map_err(|e| CalcError::CanonicalizeError(e.to_string()))?;
101        if let Some(obj) = v.as_object_mut() {
102            obj.remove("jws");
103        }
104        serde_jcs::to_vec(&v).map_err(|e| CalcError::CanonicalizeError(e.to_string()))
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::factor::FactorProvider;
112
113    struct DummyProvider;
114    impl FactorProvider for DummyProvider {
115        fn dataset_id(&self) -> &str {
116            "dummy-ds"
117        }
118        fn dataset_version(&self) -> &str {
119            "1.2.3"
120        }
121        fn gwp100(&self, _activity_uuid: &str) -> Result<f64, CalcError> {
122            Ok(1.0)
123        }
124        fn table_hash(&self) -> &str {
125            "deadbeef"
126        }
127    }
128
129    #[test]
130    fn builder_records_output_factor_and_jws() {
131        let receipt = CalculationReceipt::new("in-hash", "ruleset-x", "1.0.0")
132            .with_output_hash("out-hash")
133            .with_factor_provider(&DummyProvider)
134            .seal_with_jws("jws-token".to_owned());
135
136        assert_eq!(receipt.input_hash, "in-hash");
137        assert_eq!(receipt.output_hash, "out-hash");
138        assert_eq!(receipt.ruleset_id, "ruleset-x");
139        assert_eq!(receipt.ruleset_version, "1.0.0");
140        assert_eq!(receipt.factor_dataset_id, "dummy-ds");
141        assert_eq!(receipt.factor_dataset_version, "1.2.3");
142        assert_eq!(receipt.factor_set_hash.as_deref(), Some("deadbeef"));
143        assert_eq!(receipt.jws.as_deref(), Some("jws-token"));
144    }
145
146    #[test]
147    fn canonical_bytes_exclude_the_jws_field() {
148        let sealed = CalculationReceipt::new("in", "r", "1.0.0").seal_with_jws("secret".to_owned());
149        let bytes = sealed.canonical_bytes_for_signing().unwrap();
150        let text = String::from_utf8(bytes).unwrap();
151        assert!(
152            !text.contains("secret"),
153            "jws must be excluded from the signing payload"
154        );
155        assert!(text.contains("in"));
156    }
157}