roughtime 0.1.0

A no_std-capable Roughtime secure time-sync client with pluggable crypto backends
Documentation
//! Shared networking-client types: configuration and multi-server consensus logic.

extern crate std;

#[cfg(any(feature = "tokio-client", feature = "blocking-client"))]
use alloc::vec::Vec;
use std::time::Duration;

use crate::verify::VerifiedTime;

#[cfg(feature = "tokio-client")]
mod tokio_client;
#[cfg(feature = "tokio-client")]
pub use tokio_client::TokioClient;

#[cfg(feature = "blocking-client")]
mod blocking_client;
#[cfg(feature = "blocking-client")]
pub use blocking_client::BlockingClient;

/// Configuration shared by all networking clients.
#[derive(Debug, Clone, Copy)]
pub struct RoughtimeClientConfig {
    /// The per-server, per-attempt network timeout.
    pub timeout: Duration,
    /// The number of send attempts per server before giving up on it.
    pub retries: u8,
    /// The maximum acceptable spread between the earliest and latest verified midpoint before
    /// [`ConsensusResult::spread_warning`] is populated.
    pub max_spread: Duration,
}

impl Default for RoughtimeClientConfig {
    fn default() -> Self {
        Self {
            timeout: Duration::from_secs(15),
            retries: 2,
            max_spread: Duration::from_secs(15),
        }
    }
}

/// The result of querying multiple servers and taking the consensus (median) time.
#[derive(Debug, Clone, Copy)]
pub struct ConsensusResult {
    /// The consensus verified time (the median midpoint across all valid responses).
    pub time: VerifiedTime,
    /// Populated if the spread between the earliest and latest valid midpoint exceeded
    /// [`RoughtimeClientConfig::max_spread`] — the caller may want to log this.
    pub spread_warning: Option<Duration>,
}

/// Computes the median-midpoint consensus over a set of individually verified times.
///
/// # Errors
///
/// Returns [`crate::Error::NoValidResponses`] if `times` is empty.
#[cfg(any(feature = "tokio-client", feature = "blocking-client"))]
pub(crate) fn consensus(
    mut times: Vec<VerifiedTime>,
    max_spread: Duration,
) -> Result<ConsensusResult, crate::Error> {
    if times.is_empty() {
        return Err(crate::Error::NoValidResponses);
    }

    times.sort_unstable_by_key(|t| t.midpoint_micros);

    let median = if times.len() % 2 == 1 {
        times[times.len() / 2]
    } else {
        let mid = times.len() / 2;
        let a = times[mid - 1].midpoint_micros;
        let b = times[mid].midpoint_micros;
        VerifiedTime {
            midpoint_micros: u64::midpoint(a, b),
            radius_micros: times[mid - 1].radius_micros.max(times[mid].radius_micros),
        }
    };

    let spread_warning = if times.len() > 1 {
        let first = times.first().map_or(0, |t| t.midpoint_micros);
        let last = times.last().map_or(0, |t| t.midpoint_micros);
        let spread = Duration::from_micros(last.saturating_sub(first));
        (spread > max_spread).then_some(spread)
    } else {
        None
    };

    Ok(ConsensusResult {
        time: median,
        spread_warning,
    })
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used)]

    use alloc::vec;

    use super::{Duration, VerifiedTime, consensus};
    use crate::Error;

    fn time(midpoint_micros: u64) -> VerifiedTime {
        VerifiedTime {
            midpoint_micros,
            radius_micros: 1_000_000,
        }
    }

    #[test]
    fn empty_input_is_no_valid_responses() {
        let err = consensus(vec![], Duration::from_secs(10)).unwrap_err();
        assert_eq!(err, Error::NoValidResponses);
    }

    #[test]
    fn single_response_is_its_own_median_with_no_spread_warning() {
        let result = consensus(vec![time(100)], Duration::from_secs(10)).unwrap();
        assert_eq!(result.time.midpoint_micros, 100);
        assert_eq!(result.spread_warning, None);
    }

    #[test]
    fn odd_count_takes_the_middle_value() {
        let result = consensus(vec![time(30), time(10), time(20)], Duration::from_secs(10)).unwrap();
        assert_eq!(result.time.midpoint_micros, 20);
    }

    #[test]
    fn even_count_averages_the_two_middle_values() {
        let result = consensus(
            vec![time(10), time(20), time(30), time(40)],
            Duration::from_secs(10),
        )
        .unwrap();
        assert_eq!(result.time.midpoint_micros, 25);
    }

    #[test]
    fn spread_beyond_max_is_flagged() {
        let result = consensus(
            vec![time(0), time(5_000_000)],
            Duration::from_secs(1),
        )
        .unwrap();
        assert_eq!(result.spread_warning, Some(Duration::from_secs(5)));
    }

    #[test]
    fn spread_within_max_is_not_flagged() {
        let result = consensus(
            vec![time(0), time(500_000)],
            Duration::from_secs(1),
        )
        .unwrap();
        assert_eq!(result.spread_warning, None);
    }
}