vex_anchor/
opentimestamps.rs1use 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
14const 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#[derive(Debug, Clone)]
26pub struct OpenTimestampsAnchor {
27 client: reqwest::Client,
28}
29
30impl OpenTimestampsAnchor {
31 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 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 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}