Skip to main content

cpop_protocol/rfc/
vdf.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! RFC-compliant VDF proof structures.
4//!
5//! Implements the CDDL-defined VDF structures from draft-condrey-rats-pop-01.
6//! These structures ensure minimum elapsed time verification through
7//! verifiable delay functions.
8
9use serde::{Deserialize, Serialize};
10
11use super::serde_helpers::{hex_bytes, hex_bytes_vec};
12use super::wire_types::components::{SWF_MAX_DURATION_FACTOR, SWF_MIN_DURATION_FACTOR};
13
14/// RFC-compliant VDF proof structure.
15/// ```cddl
16/// vdf-proof = {
17///   1: bstr .size 32,          ; challenge (input)
18///   2: bstr .size 64,          ; output (proof result)
19///   3: uint,                   ; iterations (T parameter)
20///   4: uint,                   ; duration-ms (measured wall time)
21///   5: calibration-attestation ; calibration reference
22/// }
23/// ```
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct VdfProofRfc {
26    #[serde(rename = "1", with = "hex_bytes")]
27    pub challenge: [u8; 32],
28
29    /// 64-byte Wesolowski proof output.
30    #[serde(rename = "2", with = "hex_bytes")]
31    pub output: [u8; 64],
32
33    #[serde(rename = "3")]
34    pub iterations: u64,
35
36    #[serde(rename = "4")]
37    pub duration_ms: u64,
38
39    #[serde(rename = "5")]
40    pub calibration: CalibrationAttestation,
41}
42
43impl VdfProofRfc {
44    /// Create a VDF proof from all required fields.
45    pub fn new(
46        challenge: [u8; 32],
47        output: [u8; 64],
48        iterations: u64,
49        duration_ms: u64,
50        calibration: CalibrationAttestation,
51    ) -> Self {
52        Self {
53            challenge,
54            output,
55            iterations,
56            duration_ms,
57            calibration,
58        }
59    }
60
61    /// Compute the minimum expected wall time from calibration data.
62    pub fn minimum_elapsed_ms(&self) -> u64 {
63        // Integer arithmetic avoids f64 precision / NaN edge cases
64        if self.calibration.iterations_per_second > 0 {
65            self.iterations
66                .saturating_mul(1000)
67                .checked_div(self.calibration.iterations_per_second)
68                .unwrap_or(self.duration_ms)
69        } else {
70            self.duration_ms
71        }
72    }
73
74    /// Return `true` if claimed duration is consistent with calibration (5% tolerance).
75    pub fn is_duration_consistent(&self) -> bool {
76        let minimum = self.minimum_elapsed_ms();
77        // 5% tolerance for timing variance
78        let threshold = minimum.saturating_sub(minimum / 20);
79        self.duration_ms >= threshold
80    }
81
82    /// IETF-mandated `[SWF_MIN_DURATION_FACTOR, SWF_MAX_DURATION_FACTOR]` bounds check.
83    pub fn is_duration_within_spec_bounds(&self) -> bool {
84        let expected = self.minimum_elapsed_ms();
85        if expected == 0 || self.duration_ms == 0 {
86            return false;
87        }
88        let ratio = self.duration_ms as f64 / expected as f64;
89        (SWF_MIN_DURATION_FACTOR..=SWF_MAX_DURATION_FACTOR).contains(&ratio)
90    }
91
92    /// Higher ratio = faster hardware (potential gaming).
93    pub fn iterations_per_ms(&self) -> f64 {
94        if self.duration_ms > 0 {
95            self.iterations as f64 / self.duration_ms as f64
96        } else {
97            0.0
98        }
99    }
100
101    /// Validate all fields and return a list of errors (empty if valid).
102    pub fn validate(&self) -> Vec<String> {
103        let mut errors = Vec::new();
104
105        if self.challenge == [0u8; 32] {
106            errors.push("challenge must be non-zero".to_string());
107        }
108
109        if self.output == [0u8; 64] {
110            errors.push("output must be non-zero".to_string());
111        }
112
113        if self.iterations == 0 {
114            errors.push("iterations must be non-zero".to_string());
115        }
116
117        if self.duration_ms == 0 {
118            errors.push("duration_ms must be non-zero".to_string());
119        }
120
121        errors.extend(self.calibration.validate_structure());
122
123        if self.calibration.iterations_per_second > 0 && self.iterations > 0 && self.duration_ms > 0
124        {
125            if !self.is_duration_consistent() {
126                errors.push(format!(
127                    "duration_ms ({}) is inconsistent with expected minimum ({} ms) based on calibration",
128                    self.duration_ms,
129                    self.minimum_elapsed_ms()
130                ));
131            }
132            if !self.is_duration_within_spec_bounds() {
133                let expected = self.minimum_elapsed_ms();
134                let ratio = self.duration_ms as f64 / expected as f64;
135                errors.push(format!(
136                    "duration ratio {ratio:.2}x outside spec bounds [{SWF_MIN_DURATION_FACTOR}x, {SWF_MAX_DURATION_FACTOR}x]",
137                ));
138            }
139        }
140
141        errors
142    }
143
144    /// Return `true` if `validate()` produces no errors.
145    pub fn is_valid(&self) -> bool {
146        self.validate().is_empty()
147    }
148}
149
150/// Calibration reference per draft-condrey-rats-pop-01.
151/// ```cddl
152/// calibration-attestation = {
153///   1: uint,                   ; iterations-per-second (baseline rate)
154///   2: tstr,                   ; hardware-class (device classification)
155///   3: bstr,                   ; calibration-signature (signed attestation)
156///   4: uint,                   ; timestamp (calibration time)
157///   ? 5: tstr                  ; calibration-authority (optional issuer)
158/// }
159/// ```
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161pub struct CalibrationAttestation {
162    #[serde(rename = "1")]
163    pub iterations_per_second: u64,
164
165    /// E.g. "mobile-arm64", "desktop-x86_64", "server-xeon".
166    #[serde(rename = "2")]
167    pub hardware_class: String,
168
169    #[serde(rename = "3", with = "hex_bytes_vec")]
170    pub calibration_signature: Vec<u8>,
171
172    #[serde(rename = "4")]
173    pub timestamp: u64,
174
175    #[serde(rename = "5", skip_serializing_if = "Option::is_none")]
176    pub calibration_authority: Option<String>,
177}
178
179impl CalibrationAttestation {
180    /// Create a calibration attestation without authority.
181    pub fn new(
182        iterations_per_second: u64,
183        hardware_class: String,
184        calibration_signature: Vec<u8>,
185        timestamp: u64,
186    ) -> Self {
187        Self {
188            iterations_per_second,
189            hardware_class,
190            calibration_signature,
191            timestamp,
192            calibration_authority: None,
193        }
194    }
195
196    /// Create a calibration attestation with a named authority.
197    pub fn with_authority(
198        iterations_per_second: u64,
199        hardware_class: String,
200        calibration_signature: Vec<u8>,
201        timestamp: u64,
202        authority: String,
203    ) -> Self {
204        Self {
205            iterations_per_second,
206            hardware_class,
207            calibration_signature,
208            timestamp,
209            calibration_authority: Some(authority),
210        }
211    }
212
213    /// Return the age of this calibration in seconds.
214    pub fn age_seconds(&self, current_time: u64) -> u64 {
215        current_time.saturating_sub(self.timestamp)
216    }
217
218    /// 24-hour freshness window.
219    pub fn is_fresh(&self, current_time: u64) -> bool {
220        self.age_seconds(current_time) < 86400
221    }
222
223    /// Structural validation only — does NOT verify the signature.
224    pub fn validate_structure(&self) -> Vec<String> {
225        let mut errors = Vec::new();
226
227        if self.iterations_per_second == 0 {
228            errors.push("calibration.iterations_per_second must be non-zero".to_string());
229        }
230
231        if self.hardware_class.is_empty() {
232            errors.push("calibration.hardware_class must be non-empty".to_string());
233        }
234
235        if self.calibration_signature.is_empty() {
236            errors.push("calibration.calibration_signature must be non-empty".to_string());
237        }
238
239        if self.timestamp == 0 {
240            errors.push("calibration.timestamp must be non-zero".to_string());
241        }
242
243        errors
244    }
245
246    /// Return `true` if structural validation passes.
247    pub fn is_valid(&self) -> bool {
248        self.validate_structure().is_empty()
249    }
250}
251
252/// VDF algorithm identifier.
253#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
254#[serde(rename_all = "snake_case")]
255#[derive(Default)]
256pub enum VdfAlgorithm {
257    /// Wesolowski VDF (default).
258    #[default]
259    Wesolowski,
260    /// Pietrzak VDF.
261    Pietrzak,
262    /// RSA-2048 based VDF.
263    Rsa2048,
264}
265
266/// Extended VDF proof with algorithm selection and optional checkpoints.
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
268pub struct VdfProofExtended {
269    pub proof: VdfProofRfc,
270    pub algorithm: VdfAlgorithm,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub checkpoints: Option<Vec<VdfCheckpoint>>,
273}
274
275/// Intermediate checkpoint for partial verification of long VDF proofs.
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
277pub struct VdfCheckpoint {
278    pub iteration: u64,
279    #[serde(with = "hex_bytes")]
280    pub value: [u8; 64],
281    pub elapsed_ms: u64,
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_vdf_proof_creation() {
290        let calibration = CalibrationAttestation::new(
291            1_000_000,
292            "desktop-x86_64".to_string(),
293            vec![0u8; 64],
294            1700000000,
295        );
296
297        let proof = VdfProofRfc::new([1u8; 32], [2u8; 64], 1_000_000, 1000, calibration);
298
299        assert_eq!(proof.iterations, 1_000_000);
300        assert_eq!(proof.duration_ms, 1000);
301    }
302
303    #[test]
304    fn test_minimum_elapsed_calculation() {
305        let calibration = CalibrationAttestation::new(1_000_000, "test".to_string(), vec![], 0);
306
307        let proof = VdfProofRfc::new([0u8; 32], [0u8; 64], 2_000_000, 2500, calibration);
308
309        assert_eq!(proof.minimum_elapsed_ms(), 2000);
310        assert!(proof.is_duration_consistent());
311    }
312
313    #[test]
314    fn test_duration_inconsistent_when_too_fast() {
315        let calibration = CalibrationAttestation::new(1_000_000, "test".to_string(), vec![], 0);
316
317        let proof = VdfProofRfc::new(
318            [0u8; 32],
319            [0u8; 64],
320            2_000_000,
321            500, // Impossibly fast
322            calibration,
323        );
324
325        assert!(!proof.is_duration_consistent());
326    }
327
328    #[test]
329    fn test_calibration_freshness() {
330        let calibration =
331            CalibrationAttestation::new(1_000_000, "test".to_string(), vec![], 1700000000);
332
333        assert!(calibration.is_fresh(1700000000 + 3600));
334        assert!(calibration.is_fresh(1700000000 + 86000));
335        assert!(!calibration.is_fresh(1700000000 + 90000));
336    }
337
338    #[test]
339    fn test_vdf_proof_serialization() {
340        let calibration = CalibrationAttestation::with_authority(
341            500_000,
342            "mobile-arm64".to_string(),
343            vec![0xAB; 32],
344            1700000000,
345            "writerslogic.com".to_string(),
346        );
347
348        let proof = VdfProofRfc::new([0xDE; 32], [0xAD; 64], 500_000, 1000, calibration);
349
350        let json = serde_json::to_string(&proof).expect("JSON serialization failed");
351        assert!(json.contains("\"1\""));
352        assert!(json.contains("\"2\""));
353
354        let decoded: VdfProofRfc =
355            serde_json::from_str(&json).expect("JSON deserialization failed");
356        assert_eq!(decoded, proof);
357    }
358
359    #[test]
360    fn test_iterations_per_ms() {
361        let calibration = CalibrationAttestation::new(1_000_000, "test".to_string(), vec![1u8], 1);
362
363        let proof = VdfProofRfc::new([1u8; 32], [1u8; 64], 1_000_000, 1000, calibration);
364
365        assert!((proof.iterations_per_ms() - 1000.0).abs() < 0.001);
366    }
367
368    #[test]
369    fn test_vdf_proof_validate_valid() {
370        let calibration = CalibrationAttestation::new(
371            1_000_000,
372            "desktop-x86_64".to_string(),
373            vec![0xAB; 64],
374            1700000000,
375        );
376
377        let proof = VdfProofRfc::new([1u8; 32], [2u8; 64], 1_000_000, 1000, calibration);
378
379        assert!(proof.is_valid());
380        assert!(proof.validate().is_empty());
381    }
382
383    #[test]
384    fn test_vdf_proof_validate_zero_challenge() {
385        let calibration =
386            CalibrationAttestation::new(1_000_000, "test".to_string(), vec![1u8], 1700000000);
387
388        let proof = VdfProofRfc::new([0u8; 32], [2u8; 64], 1_000_000, 1000, calibration);
389
390        let errors = proof.validate();
391        assert!(errors
392            .iter()
393            .any(|e| e.contains("challenge must be non-zero")));
394        assert!(!proof.is_valid());
395    }
396
397    #[test]
398    fn test_vdf_proof_validate_zero_output() {
399        let calibration =
400            CalibrationAttestation::new(1_000_000, "test".to_string(), vec![1u8], 1700000000);
401
402        let proof = VdfProofRfc::new([1u8; 32], [0u8; 64], 1_000_000, 1000, calibration);
403
404        let errors = proof.validate();
405        assert!(errors.iter().any(|e| e.contains("output must be non-zero")));
406        assert!(!proof.is_valid());
407    }
408
409    #[test]
410    fn test_vdf_proof_validate_zero_iterations() {
411        let calibration =
412            CalibrationAttestation::new(1_000_000, "test".to_string(), vec![1u8], 1700000000);
413
414        let proof = VdfProofRfc::new([1u8; 32], [2u8; 64], 0, 1000, calibration);
415
416        let errors = proof.validate();
417        assert!(errors
418            .iter()
419            .any(|e| e.contains("iterations must be non-zero")));
420        assert!(!proof.is_valid());
421    }
422
423    #[test]
424    fn test_vdf_proof_validate_zero_duration() {
425        let calibration =
426            CalibrationAttestation::new(1_000_000, "test".to_string(), vec![1u8], 1700000000);
427
428        let proof = VdfProofRfc::new([1u8; 32], [2u8; 64], 1_000_000, 0, calibration);
429
430        let errors = proof.validate();
431        assert!(errors
432            .iter()
433            .any(|e| e.contains("duration_ms must be non-zero")));
434        assert!(!proof.is_valid());
435    }
436
437    #[test]
438    fn test_vdf_proof_validate_inconsistent_duration() {
439        let calibration =
440            CalibrationAttestation::new(1_000_000, "test".to_string(), vec![1u8], 1700000000);
441
442        let proof = VdfProofRfc::new(
443            [1u8; 32],
444            [2u8; 64],
445            2_000_000,
446            500, // Impossibly fast
447            calibration,
448        );
449
450        let errors = proof.validate();
451        assert!(errors
452            .iter()
453            .any(|e| e.contains("duration_ms") && e.contains("inconsistent")));
454        assert!(!proof.is_valid());
455    }
456
457    #[test]
458    fn test_calibration_validate_valid() {
459        let calibration = CalibrationAttestation::new(
460            1_000_000,
461            "desktop-x86_64".to_string(),
462            vec![0xAB; 64],
463            1700000000,
464        );
465
466        assert!(calibration.is_valid());
467        assert!(calibration.validate_structure().is_empty());
468    }
469
470    #[test]
471    fn test_calibration_validate_zero_iterations_per_second() {
472        let calibration = CalibrationAttestation::new(0, "test".to_string(), vec![1u8], 1700000000);
473
474        let errors = calibration.validate_structure();
475        assert!(errors
476            .iter()
477            .any(|e| e.contains("iterations_per_second must be non-zero")));
478        assert!(!calibration.is_valid());
479    }
480
481    #[test]
482    fn test_calibration_validate_empty_hardware_class() {
483        let calibration =
484            CalibrationAttestation::new(1_000_000, "".to_string(), vec![1u8], 1700000000);
485
486        let errors = calibration.validate_structure();
487        assert!(errors
488            .iter()
489            .any(|e| e.contains("hardware_class must be non-empty")));
490        assert!(!calibration.is_valid());
491    }
492
493    #[test]
494    fn test_calibration_validate_empty_signature() {
495        let calibration =
496            CalibrationAttestation::new(1_000_000, "test".to_string(), vec![], 1700000000);
497
498        let errors = calibration.validate_structure();
499        assert!(errors
500            .iter()
501            .any(|e| e.contains("calibration_signature must be non-empty")));
502        assert!(!calibration.is_valid());
503    }
504
505    #[test]
506    fn test_calibration_validate_zero_timestamp() {
507        let calibration = CalibrationAttestation::new(1_000_000, "test".to_string(), vec![1u8], 0);
508
509        let errors = calibration.validate_structure();
510        assert!(errors
511            .iter()
512            .any(|e| e.contains("timestamp must be non-zero")));
513        assert!(!calibration.is_valid());
514    }
515
516    #[test]
517    fn test_vdf_proof_validate_multiple_errors() {
518        let calibration = CalibrationAttestation::new(0, "".to_string(), vec![], 0);
519
520        let proof = VdfProofRfc::new([0u8; 32], [0u8; 64], 0, 0, calibration);
521
522        let errors = proof.validate();
523        assert!(errors.len() >= 8);
524        assert!(!proof.is_valid());
525    }
526}