1use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
32use chrono::Utc;
33use std::io::{Error as IoError, ErrorKind};
34
35use super::record::TimestampRecord;
36use crate::{DocumentId, Error, Result};
37
38pub mod calendars {
40 pub const ALICE: &str = "https://alice.btc.calendar.opentimestamps.org";
42 pub const BOB: &str = "https://bob.btc.calendar.opentimestamps.org";
44 pub const FINNEY: &str = "https://finney.calendar.eternitywall.com";
46 pub const CATALLAXY: &str = "https://ots.btc.catallaxy.com";
48}
49
50#[derive(Debug, Clone)]
56pub struct OtsClient {
57 calendars: Vec<String>,
59 client: reqwest::Client,
61 timeout_secs: u64,
63}
64
65impl Default for OtsClient {
66 fn default() -> Self {
67 Self::new()
68 }
69}
70
71impl OtsClient {
72 #[must_use]
74 pub fn new() -> Self {
75 Self {
76 calendars: vec![
77 calendars::ALICE.to_string(),
78 calendars::BOB.to_string(),
79 calendars::FINNEY.to_string(),
80 ],
81 client: reqwest::Client::new(),
82 timeout_secs: 30,
83 }
84 }
85
86 #[must_use]
88 pub fn with_calendars(calendars: Vec<String>) -> Self {
89 Self {
90 calendars,
91 client: reqwest::Client::new(),
92 timeout_secs: 30,
93 }
94 }
95
96 #[must_use]
98 pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
99 self.timeout_secs = timeout_secs;
100 self
101 }
102
103 pub async fn acquire_timestamp(&self, document_id: &DocumentId) -> Result<TimestampRecord> {
122 if document_id.algorithm().as_str() != "sha256" {
124 return Err(Error::InvalidManifest {
125 reason: "OpenTimestamps requires SHA-256 hash algorithm".to_string(),
126 });
127 }
128
129 let hash_hex = document_id.hex_digest();
131 let hash_bytes = hex_to_bytes(&hash_hex)?;
132
133 let mut last_error = None;
135 for calendar_url in &self.calendars {
136 match self.submit_to_calendar(calendar_url, &hash_bytes).await {
137 Ok(proof) => {
138 return Ok(TimestampRecord::open_timestamps(
139 Utc::now(),
140 BASE64.encode(&proof),
141 ));
142 }
143 Err(e) => {
144 last_error = Some(e);
145 }
146 }
147 }
148
149 Err(last_error.unwrap_or_else(|| {
150 Error::Io(IoError::new(
151 ErrorKind::NotConnected,
152 "No calendar servers configured",
153 ))
154 }))
155 }
156
157 async fn submit_to_calendar(&self, calendar_url: &str, hash: &[u8]) -> Result<Vec<u8>> {
159 let url = format!("{calendar_url}/digest");
160
161 let response = self
162 .client
163 .post(&url)
164 .timeout(std::time::Duration::from_secs(self.timeout_secs))
165 .header("Content-Type", "application/x-www-form-urlencoded")
166 .body(hash.to_vec())
167 .send()
168 .await
169 .map_err(|e| {
170 Error::Io(IoError::new(
171 ErrorKind::ConnectionRefused,
172 format!("Failed to contact calendar server: {e}"),
173 ))
174 })?;
175
176 if !response.status().is_success() {
177 let status = response.status();
178 let text = response.text().await.unwrap_or_default();
179 return Err(Error::Io(IoError::other(format!(
180 "Calendar server returned error: {status} {text}"
181 ))));
182 }
183
184 let proof_bytes = response.bytes().await.map_err(|e| {
185 Error::Io(IoError::new(
186 ErrorKind::InvalidData,
187 format!("Failed to read response: {e}"),
188 ))
189 })?;
190
191 Ok(proof_bytes.to_vec())
192 }
193
194 pub async fn upgrade_timestamp(&self, timestamp: &TimestampRecord) -> Result<UpgradeResult> {
212 let proof_bytes = BASE64.decode(×tamp.token).map_err(|e| {
214 Error::Io(IoError::new(
215 ErrorKind::InvalidData,
216 format!("Invalid timestamp token: {e}"),
217 ))
218 })?;
219
220 for calendar_url in &self.calendars {
222 if let Ok(Some(upgraded)) = self.upgrade_from_calendar(calendar_url, &proof_bytes).await
223 {
224 let upgraded_record = TimestampRecord {
225 method: timestamp.method,
226 authority: timestamp.authority.clone(),
227 time: timestamp.time,
228 token: BASE64.encode(&upgraded),
229 transaction_id: extract_bitcoin_txid(&upgraded),
230 };
231 return Ok(UpgradeResult::Complete(upgraded_record));
232 }
233 }
235
236 Ok(UpgradeResult::Pending {
237 message: "Timestamp not yet anchored to Bitcoin".to_string(),
238 })
239 }
240
241 async fn upgrade_from_calendar(
243 &self,
244 calendar_url: &str,
245 proof: &[u8],
246 ) -> Result<Option<Vec<u8>>> {
247 let url = format!("{calendar_url}/timestamp");
255
256 let response = self
257 .client
258 .post(&url)
259 .timeout(std::time::Duration::from_secs(self.timeout_secs))
260 .header("Content-Type", "application/octet-stream")
261 .body(proof.to_vec())
262 .send()
263 .await
264 .map_err(|e| {
265 Error::Io(IoError::new(
266 ErrorKind::ConnectionRefused,
267 format!("Failed to contact calendar server: {e}"),
268 ))
269 })?;
270
271 match response.status().as_u16() {
272 200 => {
273 let upgraded_bytes = response.bytes().await.map_err(|e| {
275 Error::Io(IoError::new(
276 ErrorKind::InvalidData,
277 format!("Failed to read response: {e}"),
278 ))
279 })?;
280 Ok(Some(upgraded_bytes.to_vec()))
281 }
282 404 => {
283 Ok(None)
285 }
286 status => {
287 let text = response.text().await.unwrap_or_default();
288 Err(Error::Io(IoError::other(format!(
289 "Calendar server returned error: {status} {text}"
290 ))))
291 }
292 }
293 }
294
295 pub async fn check_status(&self, timestamp: &TimestampRecord) -> Result<TimestampStatus> {
303 let proof_bytes = BASE64.decode(×tamp.token).map_err(|e| {
305 Error::Io(IoError::new(
306 ErrorKind::InvalidData,
307 format!("Invalid timestamp token: {e}"),
308 ))
309 })?;
310
311 if is_complete_proof(&proof_bytes) {
313 return Ok(TimestampStatus::Complete {
314 bitcoin_txid: extract_bitcoin_txid(&proof_bytes),
315 block_height: extract_block_height(&proof_bytes),
316 });
317 }
318
319 for calendar_url in &self.calendars {
321 if let Ok(Some(_)) = self.upgrade_from_calendar(calendar_url, &proof_bytes).await {
322 return Ok(TimestampStatus::Ready);
323 }
324 }
325
326 Ok(TimestampStatus::Pending)
327 }
328
329 pub fn verify_timestamp(
340 &self,
341 timestamp: &TimestampRecord,
342 document_id: &DocumentId,
343 ) -> Result<TimestampVerification> {
344 let proof_bytes = BASE64.decode(×tamp.token).map_err(|e| {
346 Error::Io(IoError::new(
347 ErrorKind::InvalidData,
348 format!("Invalid timestamp token: {e}"),
349 ))
350 })?;
351
352 if proof_bytes.is_empty() {
354 return Ok(TimestampVerification {
355 valid: false,
356 status: VerificationStatus::Invalid,
357 message: "Empty proof".to_string(),
358 });
359 }
360
361 let _ = document_id;
365
366 Ok(TimestampVerification {
367 valid: false,
368 status: VerificationStatus::Pending,
369 message: "Timestamp proof present but unverified (full verification requires upgrade)"
370 .to_string(),
371 })
372 }
373}
374
375#[derive(Debug, Clone)]
377pub struct TimestampVerification {
378 pub valid: bool,
380 pub status: VerificationStatus,
382 pub message: String,
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum VerificationStatus {
389 Pending,
391 Complete,
393 Invalid,
395}
396
397#[derive(Debug, Clone)]
399pub enum UpgradeResult {
400 Complete(TimestampRecord),
402 Pending {
404 message: String,
406 },
407}
408
409impl UpgradeResult {
410 #[must_use]
412 pub fn is_complete(&self) -> bool {
413 matches!(self, Self::Complete(_))
414 }
415
416 #[must_use]
418 pub fn into_record(self) -> Option<TimestampRecord> {
419 match self {
420 Self::Complete(record) => Some(record),
421 Self::Pending { .. } => None,
422 }
423 }
424}
425
426#[derive(Debug, Clone, PartialEq, Eq)]
428pub enum TimestampStatus {
429 Pending,
431 Ready,
433 Complete {
435 bitcoin_txid: Option<String>,
437 block_height: Option<u64>,
439 },
440}
441
442impl TimestampStatus {
443 #[must_use]
445 pub fn is_complete(&self) -> bool {
446 matches!(self, Self::Complete { .. })
447 }
448
449 #[must_use]
451 pub fn is_pending(&self) -> bool {
452 matches!(self, Self::Pending)
453 }
454}
455
456impl std::fmt::Display for TimestampStatus {
457 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
458 match self {
459 Self::Pending => write!(f, "Pending"),
460 Self::Ready => write!(f, "Ready for upgrade"),
461 Self::Complete {
462 bitcoin_txid,
463 block_height,
464 } => {
465 write!(f, "Complete")?;
466 if let Some(txid) = bitcoin_txid {
467 write!(f, " (tx: {txid})")?;
468 }
469 if let Some(height) = block_height {
470 write!(f, " (block: {height})")?;
471 }
472 Ok(())
473 }
474 }
475 }
476}
477
478fn is_complete_proof(proof: &[u8]) -> bool {
480 const BITCOIN_ATTESTATION_TAG: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01];
484
485 proof
486 .windows(8)
487 .any(|window| window == BITCOIN_ATTESTATION_TAG)
488}
489
490fn extract_bitcoin_txid(proof: &[u8]) -> Option<String> {
492 const BITCOIN_ATTESTATION_TAG: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01];
496
497 for (i, window) in proof.windows(8).enumerate() {
498 if window == BITCOIN_ATTESTATION_TAG {
499 if proof.len() > i + 8 + 32 {
502 let txid_bytes = &proof[i + 8..i + 8 + 32];
503 let mut reversed = txid_bytes.to_vec();
505 reversed.reverse();
506 return Some(hex::encode(reversed));
507 }
508 }
509 }
510 None
511}
512
513fn extract_block_height(proof: &[u8]) -> Option<u64> {
515 let _ = proof;
518 None
519}
520
521mod hex {
523 pub fn encode(bytes: impl AsRef<[u8]>) -> String {
525 bytes.as_ref().iter().fold(
526 String::with_capacity(bytes.as_ref().len() * 2),
527 |mut acc, b| {
528 use std::fmt::Write;
529 let _ = write!(acc, "{b:02x}");
530 acc
531 },
532 )
533 }
534}
535
536fn hex_to_bytes(hex: &str) -> Result<Vec<u8>> {
538 let hex = hex.trim();
539 if !hex.len().is_multiple_of(2) {
540 return Err(Error::InvalidHashFormat {
541 value: "Invalid hex string length".to_string(),
542 });
543 }
544
545 (0..hex.len())
546 .step_by(2)
547 .map(|i| {
548 u8::from_str_radix(&hex[i..i + 2], 16).map_err(|_| Error::InvalidHashFormat {
549 value: "Invalid hex character".to_string(),
550 })
551 })
552 .collect()
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use crate::{HashAlgorithm, Hasher};
559
560 #[test]
561 fn test_ots_client_creation() {
562 let client = OtsClient::new();
563 assert!(!client.calendars.is_empty());
564 }
565
566 #[test]
567 fn test_ots_client_custom_calendars() {
568 let client = OtsClient::with_calendars(vec!["https://custom.example.com".to_string()]);
569 assert_eq!(client.calendars.len(), 1);
570 }
571
572 #[test]
573 fn test_hex_to_bytes() {
574 let bytes = hex_to_bytes("deadbeef").unwrap();
575 assert_eq!(bytes, vec![0xde, 0xad, 0xbe, 0xef]);
576 }
577
578 #[test]
579 fn test_hex_to_bytes_invalid() {
580 assert!(hex_to_bytes("deadbee").is_err()); assert!(hex_to_bytes("deadbeeg").is_err()); }
583
584 #[test]
585 fn test_verify_empty_proof() {
586 let client = OtsClient::new();
587 let doc_id = Hasher::hash(HashAlgorithm::Sha256, b"test");
588 let timestamp = TimestampRecord::open_timestamps(Utc::now(), "");
589
590 let result = client.verify_timestamp(×tamp, &doc_id).unwrap();
591 assert!(!result.valid);
592 assert_eq!(result.status, VerificationStatus::Invalid);
593 }
594
595 #[test]
596 fn test_verify_basic_proof() {
597 let client = OtsClient::new();
598 let doc_id = Hasher::hash(HashAlgorithm::Sha256, b"test");
599 let timestamp =
600 TimestampRecord::open_timestamps(Utc::now(), BASE64.encode(b"some proof data"));
601
602 let result = client.verify_timestamp(×tamp, &doc_id).unwrap();
603 assert!(!result.valid);
604 assert_eq!(result.status, VerificationStatus::Pending);
605 }
606}