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: true,
368 status: VerificationStatus::Pending,
369 message: "Timestamp proof present (full verification requires upgrade)".to_string(),
370 })
371 }
372}
373
374#[derive(Debug, Clone)]
376pub struct TimestampVerification {
377 pub valid: bool,
379 pub status: VerificationStatus,
381 pub message: String,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387pub enum VerificationStatus {
388 Pending,
390 Complete,
392 Invalid,
394}
395
396#[derive(Debug, Clone)]
398pub enum UpgradeResult {
399 Complete(TimestampRecord),
401 Pending {
403 message: String,
405 },
406}
407
408impl UpgradeResult {
409 #[must_use]
411 pub fn is_complete(&self) -> bool {
412 matches!(self, Self::Complete(_))
413 }
414
415 #[must_use]
417 pub fn into_record(self) -> Option<TimestampRecord> {
418 match self {
419 Self::Complete(record) => Some(record),
420 Self::Pending { .. } => None,
421 }
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
427pub enum TimestampStatus {
428 Pending,
430 Ready,
432 Complete {
434 bitcoin_txid: Option<String>,
436 block_height: Option<u64>,
438 },
439}
440
441impl TimestampStatus {
442 #[must_use]
444 pub fn is_complete(&self) -> bool {
445 matches!(self, Self::Complete { .. })
446 }
447
448 #[must_use]
450 pub fn is_pending(&self) -> bool {
451 matches!(self, Self::Pending)
452 }
453}
454
455impl std::fmt::Display for TimestampStatus {
456 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 match self {
458 Self::Pending => write!(f, "Pending"),
459 Self::Ready => write!(f, "Ready for upgrade"),
460 Self::Complete {
461 bitcoin_txid,
462 block_height,
463 } => {
464 write!(f, "Complete")?;
465 if let Some(txid) = bitcoin_txid {
466 write!(f, " (tx: {txid})")?;
467 }
468 if let Some(height) = block_height {
469 write!(f, " (block: {height})")?;
470 }
471 Ok(())
472 }
473 }
474 }
475}
476
477fn is_complete_proof(proof: &[u8]) -> bool {
479 const BITCOIN_ATTESTATION_TAG: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01];
483
484 proof
485 .windows(8)
486 .any(|window| window == BITCOIN_ATTESTATION_TAG)
487}
488
489fn extract_bitcoin_txid(proof: &[u8]) -> Option<String> {
491 const BITCOIN_ATTESTATION_TAG: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01];
495
496 for (i, window) in proof.windows(8).enumerate() {
497 if window == BITCOIN_ATTESTATION_TAG {
498 if proof.len() > i + 8 + 32 {
501 let txid_bytes = &proof[i + 8..i + 8 + 32];
502 let mut reversed = txid_bytes.to_vec();
504 reversed.reverse();
505 return Some(hex::encode(reversed));
506 }
507 }
508 }
509 None
510}
511
512fn extract_block_height(proof: &[u8]) -> Option<u64> {
514 let _ = proof;
517 None
518}
519
520mod hex {
522 pub fn encode(bytes: impl AsRef<[u8]>) -> String {
524 bytes.as_ref().iter().fold(
525 String::with_capacity(bytes.as_ref().len() * 2),
526 |mut acc, b| {
527 use std::fmt::Write;
528 let _ = write!(acc, "{b:02x}");
529 acc
530 },
531 )
532 }
533}
534
535fn hex_to_bytes(hex: &str) -> Result<Vec<u8>> {
537 let hex = hex.trim();
538 if !hex.len().is_multiple_of(2) {
539 return Err(Error::InvalidHashFormat {
540 value: "Invalid hex string length".to_string(),
541 });
542 }
543
544 (0..hex.len())
545 .step_by(2)
546 .map(|i| {
547 u8::from_str_radix(&hex[i..i + 2], 16).map_err(|_| Error::InvalidHashFormat {
548 value: "Invalid hex character".to_string(),
549 })
550 })
551 .collect()
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use crate::{HashAlgorithm, Hasher};
558
559 #[test]
560 fn test_ots_client_creation() {
561 let client = OtsClient::new();
562 assert!(!client.calendars.is_empty());
563 }
564
565 #[test]
566 fn test_ots_client_custom_calendars() {
567 let client = OtsClient::with_calendars(vec!["https://custom.example.com".to_string()]);
568 assert_eq!(client.calendars.len(), 1);
569 }
570
571 #[test]
572 fn test_hex_to_bytes() {
573 let bytes = hex_to_bytes("deadbeef").unwrap();
574 assert_eq!(bytes, vec![0xde, 0xad, 0xbe, 0xef]);
575 }
576
577 #[test]
578 fn test_hex_to_bytes_invalid() {
579 assert!(hex_to_bytes("deadbee").is_err()); assert!(hex_to_bytes("deadbeeg").is_err()); }
582
583 #[test]
584 fn test_verify_empty_proof() {
585 let client = OtsClient::new();
586 let doc_id = Hasher::hash(HashAlgorithm::Sha256, b"test");
587 let timestamp = TimestampRecord::open_timestamps(Utc::now(), "");
588
589 let result = client.verify_timestamp(×tamp, &doc_id).unwrap();
590 assert!(!result.valid);
591 assert_eq!(result.status, VerificationStatus::Invalid);
592 }
593
594 #[test]
595 fn test_verify_basic_proof() {
596 let client = OtsClient::new();
597 let doc_id = Hasher::hash(HashAlgorithm::Sha256, b"test");
598 let timestamp =
599 TimestampRecord::open_timestamps(Utc::now(), BASE64.encode(b"some proof data"));
600
601 let result = client.verify_timestamp(×tamp, &doc_id).unwrap();
602 assert!(result.valid);
603 assert_eq!(result.status, VerificationStatus::Pending);
604 }
605}