use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use chrono::Utc;
use std::io::{Error as IoError, ErrorKind};
use super::record::TimestampRecord;
use crate::{DocumentId, Error, Result};
pub mod calendars {
pub const ALICE: &str = "https://alice.btc.calendar.opentimestamps.org";
pub const BOB: &str = "https://bob.btc.calendar.opentimestamps.org";
pub const FINNEY: &str = "https://finney.calendar.eternitywall.com";
pub const CATALLAXY: &str = "https://ots.btc.catallaxy.com";
}
#[derive(Debug, Clone)]
pub struct OtsClient {
calendars: Vec<String>,
client: reqwest::Client,
timeout_secs: u64,
}
impl Default for OtsClient {
fn default() -> Self {
Self::new()
}
}
impl OtsClient {
#[must_use]
pub fn new() -> Self {
Self {
calendars: vec![
calendars::ALICE.to_string(),
calendars::BOB.to_string(),
calendars::FINNEY.to_string(),
],
client: reqwest::Client::new(),
timeout_secs: 30,
}
}
#[must_use]
pub fn with_calendars(calendars: Vec<String>) -> Self {
Self {
calendars,
client: reqwest::Client::new(),
timeout_secs: 30,
}
}
#[must_use]
pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
self.timeout_secs = timeout_secs;
self
}
pub async fn acquire_timestamp(&self, document_id: &DocumentId) -> Result<TimestampRecord> {
if document_id.algorithm().as_str() != "sha256" {
return Err(Error::InvalidManifest {
reason: "OpenTimestamps requires SHA-256 hash algorithm".to_string(),
});
}
let hash_hex = document_id.hex_digest();
let hash_bytes = hex_to_bytes(&hash_hex)?;
let mut last_error = None;
for calendar_url in &self.calendars {
match self.submit_to_calendar(calendar_url, &hash_bytes).await {
Ok(proof) => {
return Ok(TimestampRecord::open_timestamps(
Utc::now(),
BASE64.encode(&proof),
));
}
Err(e) => {
last_error = Some(e);
}
}
}
Err(last_error.unwrap_or_else(|| {
Error::Io(IoError::new(
ErrorKind::NotConnected,
"No calendar servers configured",
))
}))
}
async fn submit_to_calendar(&self, calendar_url: &str, hash: &[u8]) -> Result<Vec<u8>> {
let url = format!("{calendar_url}/digest");
let response = self
.client
.post(&url)
.timeout(std::time::Duration::from_secs(self.timeout_secs))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(hash.to_vec())
.send()
.await
.map_err(|e| {
Error::Io(IoError::new(
ErrorKind::ConnectionRefused,
format!("Failed to contact calendar server: {e}"),
))
})?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(Error::Io(IoError::other(format!(
"Calendar server returned error: {status} {text}"
))));
}
let proof_bytes = response.bytes().await.map_err(|e| {
Error::Io(IoError::new(
ErrorKind::InvalidData,
format!("Failed to read response: {e}"),
))
})?;
Ok(proof_bytes.to_vec())
}
pub async fn upgrade_timestamp(&self, timestamp: &TimestampRecord) -> Result<UpgradeResult> {
let proof_bytes = BASE64.decode(×tamp.token).map_err(|e| {
Error::Io(IoError::new(
ErrorKind::InvalidData,
format!("Invalid timestamp token: {e}"),
))
})?;
for calendar_url in &self.calendars {
if let Ok(Some(upgraded)) = self.upgrade_from_calendar(calendar_url, &proof_bytes).await
{
let upgraded_record = TimestampRecord {
method: timestamp.method,
authority: timestamp.authority.clone(),
time: timestamp.time,
token: BASE64.encode(&upgraded),
transaction_id: extract_bitcoin_txid(&upgraded),
};
return Ok(UpgradeResult::Complete(upgraded_record));
}
}
Ok(UpgradeResult::Pending {
message: "Timestamp not yet anchored to Bitcoin".to_string(),
})
}
async fn upgrade_from_calendar(
&self,
calendar_url: &str,
proof: &[u8],
) -> Result<Option<Vec<u8>>> {
let url = format!("{calendar_url}/timestamp");
let response = self
.client
.post(&url)
.timeout(std::time::Duration::from_secs(self.timeout_secs))
.header("Content-Type", "application/octet-stream")
.body(proof.to_vec())
.send()
.await
.map_err(|e| {
Error::Io(IoError::new(
ErrorKind::ConnectionRefused,
format!("Failed to contact calendar server: {e}"),
))
})?;
match response.status().as_u16() {
200 => {
let upgraded_bytes = response.bytes().await.map_err(|e| {
Error::Io(IoError::new(
ErrorKind::InvalidData,
format!("Failed to read response: {e}"),
))
})?;
Ok(Some(upgraded_bytes.to_vec()))
}
404 => {
Ok(None)
}
status => {
let text = response.text().await.unwrap_or_default();
Err(Error::Io(IoError::other(format!(
"Calendar server returned error: {status} {text}"
))))
}
}
}
pub async fn check_status(&self, timestamp: &TimestampRecord) -> Result<TimestampStatus> {
let proof_bytes = BASE64.decode(×tamp.token).map_err(|e| {
Error::Io(IoError::new(
ErrorKind::InvalidData,
format!("Invalid timestamp token: {e}"),
))
})?;
if is_complete_proof(&proof_bytes) {
return Ok(TimestampStatus::Complete {
bitcoin_txid: extract_bitcoin_txid(&proof_bytes),
block_height: extract_block_height(&proof_bytes),
});
}
for calendar_url in &self.calendars {
if let Ok(Some(_)) = self.upgrade_from_calendar(calendar_url, &proof_bytes).await {
return Ok(TimestampStatus::Ready);
}
}
Ok(TimestampStatus::Pending)
}
pub fn verify_timestamp(
&self,
timestamp: &TimestampRecord,
document_id: &DocumentId,
) -> Result<TimestampVerification> {
let proof_bytes = BASE64.decode(×tamp.token).map_err(|e| {
Error::Io(IoError::new(
ErrorKind::InvalidData,
format!("Invalid timestamp token: {e}"),
))
})?;
if proof_bytes.is_empty() {
return Ok(TimestampVerification {
valid: false,
status: VerificationStatus::Invalid,
message: "Empty proof".to_string(),
});
}
let _ = document_id;
Ok(TimestampVerification {
valid: false,
status: VerificationStatus::Pending,
message: "Timestamp proof present but unverified (full verification requires upgrade)"
.to_string(),
})
}
}
#[derive(Debug, Clone)]
pub struct TimestampVerification {
pub valid: bool,
pub status: VerificationStatus,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerificationStatus {
Pending,
Complete,
Invalid,
}
#[derive(Debug, Clone)]
pub enum UpgradeResult {
Complete(TimestampRecord),
Pending {
message: String,
},
}
impl UpgradeResult {
#[must_use]
pub fn is_complete(&self) -> bool {
matches!(self, Self::Complete(_))
}
#[must_use]
pub fn into_record(self) -> Option<TimestampRecord> {
match self {
Self::Complete(record) => Some(record),
Self::Pending { .. } => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimestampStatus {
Pending,
Ready,
Complete {
bitcoin_txid: Option<String>,
block_height: Option<u64>,
},
}
impl TimestampStatus {
#[must_use]
pub fn is_complete(&self) -> bool {
matches!(self, Self::Complete { .. })
}
#[must_use]
pub fn is_pending(&self) -> bool {
matches!(self, Self::Pending)
}
}
impl std::fmt::Display for TimestampStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pending => write!(f, "Pending"),
Self::Ready => write!(f, "Ready for upgrade"),
Self::Complete {
bitcoin_txid,
block_height,
} => {
write!(f, "Complete")?;
if let Some(txid) = bitcoin_txid {
write!(f, " (tx: {txid})")?;
}
if let Some(height) = block_height {
write!(f, " (block: {height})")?;
}
Ok(())
}
}
}
}
fn is_complete_proof(proof: &[u8]) -> bool {
const BITCOIN_ATTESTATION_TAG: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01];
proof
.windows(8)
.any(|window| window == BITCOIN_ATTESTATION_TAG)
}
fn extract_bitcoin_txid(proof: &[u8]) -> Option<String> {
const BITCOIN_ATTESTATION_TAG: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01];
for (i, window) in proof.windows(8).enumerate() {
if window == BITCOIN_ATTESTATION_TAG {
if proof.len() > i + 8 + 32 {
let txid_bytes = &proof[i + 8..i + 8 + 32];
let mut reversed = txid_bytes.to_vec();
reversed.reverse();
return Some(hex::encode(reversed));
}
}
}
None
}
fn extract_block_height(proof: &[u8]) -> Option<u64> {
let _ = proof;
None
}
mod hex {
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
bytes.as_ref().iter().fold(
String::with_capacity(bytes.as_ref().len() * 2),
|mut acc, b| {
use std::fmt::Write;
let _ = write!(acc, "{b:02x}");
acc
},
)
}
}
fn hex_to_bytes(hex: &str) -> Result<Vec<u8>> {
let hex = hex.trim();
if !hex.len().is_multiple_of(2) {
return Err(Error::InvalidHashFormat {
value: "Invalid hex string length".to_string(),
});
}
(0..hex.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&hex[i..i + 2], 16).map_err(|_| Error::InvalidHashFormat {
value: "Invalid hex character".to_string(),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{HashAlgorithm, Hasher};
#[test]
fn test_ots_client_creation() {
let client = OtsClient::new();
assert!(!client.calendars.is_empty());
}
#[test]
fn test_ots_client_custom_calendars() {
let client = OtsClient::with_calendars(vec!["https://custom.example.com".to_string()]);
assert_eq!(client.calendars.len(), 1);
}
#[test]
fn test_hex_to_bytes() {
let bytes = hex_to_bytes("deadbeef").unwrap();
assert_eq!(bytes, vec![0xde, 0xad, 0xbe, 0xef]);
}
#[test]
fn test_hex_to_bytes_invalid() {
assert!(hex_to_bytes("deadbee").is_err()); assert!(hex_to_bytes("deadbeeg").is_err()); }
#[test]
fn test_verify_empty_proof() {
let client = OtsClient::new();
let doc_id = Hasher::hash(HashAlgorithm::Sha256, b"test");
let timestamp = TimestampRecord::open_timestamps(Utc::now(), "");
let result = client.verify_timestamp(×tamp, &doc_id).unwrap();
assert!(!result.valid);
assert_eq!(result.status, VerificationStatus::Invalid);
}
#[test]
fn test_verify_basic_proof() {
let client = OtsClient::new();
let doc_id = Hasher::hash(HashAlgorithm::Sha256, b"test");
let timestamp =
TimestampRecord::open_timestamps(Utc::now(), BASE64.encode(b"some proof data"));
let result = client.verify_timestamp(×tamp, &doc_id).unwrap();
assert!(!result.valid);
assert_eq!(result.status, VerificationStatus::Pending);
}
}