vex_anchor/
opentimestamps.rs

1//! OpenTimestamps anchor backend
2//!
3//! Submits Merkle roots to the public OpenTimestamps calendar servers
4//! (https://alice.btc.calendar.opentimestamps.org) for Bitcoin blockchain anchoring.
5
6use async_trait::async_trait;
7use base64::{engine::general_purpose::STANDARD, Engine};
8use chrono::Utc;
9use vex_core::Hash;
10
11use crate::backend::{AnchorBackend, AnchorMetadata, AnchorReceipt};
12use crate::error::AnchorError;
13
14/// Public OTS calendar servers (tried in order)
15const OTS_CALENDARS: &[&str] = &[
16    "https://alice.btc.calendar.opentimestamps.org",
17    "https://bob.btc.calendar.opentimestamps.org",
18    "https://finney.calendar.eternitywall.com",
19];
20
21/// OpenTimestamps calendar anchor backend
22///
23/// Submits Merkle roots to public Bitcoin calendar servers for timestamping.
24/// Proofs become final after ~1 Bitcoin block and are verifiable with `ots verify`.
25#[derive(Debug, Clone)]
26pub struct OpenTimestampsAnchor {
27    client: reqwest::Client,
28}
29
30impl OpenTimestampsAnchor {
31    /// Create a new OpenTimestamps anchor using the public calendar servers
32    pub fn new() -> Self {
33        let client = reqwest::Client::builder()
34            .timeout(std::time::Duration::from_secs(30))
35            .user_agent("vex-anchor/0.1.5")
36            .build()
37            .expect("Failed to build OTS HTTP client");
38        Self { client }
39    }
40}
41
42impl Default for OpenTimestampsAnchor {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48#[async_trait]
49impl AnchorBackend for OpenTimestampsAnchor {
50    async fn anchor(
51        &self,
52        root: &Hash,
53        metadata: AnchorMetadata,
54    ) -> Result<AnchorReceipt, AnchorError> {
55        // OTS calendar accepts raw 32-byte SHA-256 digests
56        let digest_bytes = root.0.to_vec();
57
58        let mut last_error = AnchorError::Network("No calendars configured".to_string());
59        for calendar in OTS_CALENDARS {
60            let url = format!("{}/digest", calendar);
61            let response = self
62                .client
63                .post(&url)
64                .header("Content-Type", "application/x-www-form-urlencoded")
65                .body(digest_bytes.clone())
66                .send()
67                .await;
68
69            match response {
70                Ok(resp) if resp.status().is_success() => {
71                    let proof_bytes = resp
72                        .bytes()
73                        .await
74                        .map_err(|e| AnchorError::Network(e.to_string()))?;
75
76                    let proof_b64 = STANDARD.encode(&proof_bytes);
77                    let anchor_id = format!("{}#{}", calendar, root.to_hex());
78
79                    return Ok(AnchorReceipt {
80                        backend: self.name().to_string(),
81                        root_hash: root.to_hex(),
82                        anchor_id,
83                        anchored_at: Utc::now(),
84                        proof: Some(proof_b64),
85                        metadata,
86                    });
87                }
88                Ok(resp) => {
89                    last_error = AnchorError::Network(format!(
90                        "Calendar {} returned HTTP {}",
91                        calendar,
92                        resp.status()
93                    ));
94                }
95                Err(e) => {
96                    last_error =
97                        AnchorError::Network(format!("Calendar {} unreachable: {}", calendar, e));
98                }
99            }
100        }
101
102        Err(last_error)
103    }
104
105    async fn verify(&self, receipt: &AnchorReceipt) -> Result<bool, AnchorError> {
106        let Some(ref proof_b64) = receipt.proof else {
107            return Ok(false);
108        };
109
110        let proof_bytes = STANDARD
111            .decode(proof_b64)
112            .map_err(|e| AnchorError::VerificationFailed(format!("Invalid base64 proof: {}", e)))?;
113
114        // Non-empty proof means the OTS calendar acknowledged the submission
115        Ok(!proof_bytes.is_empty())
116    }
117
118    fn name(&self) -> &str {
119        "opentimestamps"
120    }
121
122    async fn is_healthy(&self) -> bool {
123        let url = format!("{}/digest", OTS_CALENDARS[0]);
124        self.client
125            .head(&url)
126            .send()
127            .await
128            .map(|r| r.status().as_u16() < 500)
129            .unwrap_or(false)
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::backend::AnchorMetadata;
137
138    #[test]
139    fn test_ots_anchor_name() {
140        let anchor = OpenTimestampsAnchor::new();
141        assert_eq!(anchor.name(), "opentimestamps");
142    }
143
144    #[test]
145    fn test_ots_verify_missing_proof() {
146        let receipt = AnchorReceipt {
147            backend: "opentimestamps".to_string(),
148            root_hash: "abc123".to_string(),
149            anchor_id: "ots://test#abc123".to_string(),
150            anchored_at: Utc::now(),
151            proof: None,
152            metadata: AnchorMetadata::new("test-tenant", 1),
153        };
154        assert!(receipt.proof.is_none());
155    }
156}