Skip to main content

wickra_exchange_core/
clock.rs

1//! Time and nonce correctness for signed requests.
2//!
3//! Signed requests carry a timestamp that the venue checks against its own clock
4//! within a receive window; local clock drift silently gets orders rejected.
5//! [`ServerClock`] tracks the offset against the venue's `/time` endpoint and
6//! produces adjusted timestamps. [`NonceGenerator`] yields the strictly
7//! increasing nonce some venues (Kraken) require, and [`TokenTtl`] tracks the
8//! lifetime of a per-request JWT (Coinbase/Upbit).
9//!
10//! Every method that needs "now" takes it as an argument, so the module has no
11//! hidden wall-clock and is fully deterministic under test.
12
13use std::sync::atomic::{AtomicU64, Ordering};
14
15/// Tracks the offset between the local clock and a venue's server clock.
16#[derive(Debug, Default)]
17pub struct ServerClock {
18    offset_ms: i64,
19}
20
21impl ServerClock {
22    /// A clock with zero offset (assumes local time until synced).
23    #[must_use]
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Record a sync point: the local time at which the venue reported
29    /// `server_ms`. The offset becomes `server_ms - local_ms`.
30    pub fn sync(&mut self, local_ms: i64, server_ms: i64) {
31        self.offset_ms = server_ms - local_ms;
32    }
33
34    /// The current offset (`server - local`) in milliseconds.
35    #[must_use]
36    pub fn offset_ms(&self) -> i64 {
37        self.offset_ms
38    }
39
40    /// The server-adjusted timestamp for a given local time.
41    #[must_use]
42    pub fn server_time_ms(&self, local_ms: i64) -> i64 {
43        local_ms + self.offset_ms
44    }
45}
46
47/// A strictly-increasing nonce generator. The nonce tracks wall-clock
48/// milliseconds but is forced monotonic, so calls faster than millisecond
49/// resolution still strictly increase.
50#[derive(Debug)]
51pub struct NonceGenerator {
52    last: AtomicU64,
53}
54
55impl NonceGenerator {
56    /// A generator that will produce values strictly greater than `start`.
57    #[must_use]
58    pub fn new(start: u64) -> Self {
59        Self {
60            last: AtomicU64::new(start),
61        }
62    }
63
64    /// The next nonce: the greater of `candidate_ms` and `last + 1`. Thread-safe.
65    pub fn next(&self, candidate_ms: u64) -> u64 {
66        let mut prev = self.last.load(Ordering::Relaxed);
67        loop {
68            let next = candidate_ms.max(prev + 1);
69            match self
70                .last
71                .compare_exchange_weak(prev, next, Ordering::Relaxed, Ordering::Relaxed)
72            {
73                Ok(_) => return next,
74                Err(actual) => prev = actual,
75            }
76        }
77    }
78}
79
80/// The lifetime of a short-lived token (a per-request JWT).
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct TokenTtl {
83    issued_at_ms: i64,
84    ttl_ms: i64,
85}
86
87impl TokenTtl {
88    /// A token issued at `issued_at_ms`, valid for `ttl_ms`.
89    #[must_use]
90    pub fn new(issued_at_ms: i64, ttl_ms: i64) -> Self {
91        Self {
92            issued_at_ms,
93            ttl_ms,
94        }
95    }
96
97    /// The absolute expiry time in milliseconds.
98    #[must_use]
99    pub fn expires_at_ms(&self) -> i64 {
100        self.issued_at_ms + self.ttl_ms
101    }
102
103    /// Whether the token is expired at `now_ms`.
104    #[must_use]
105    pub fn is_expired(&self, now_ms: i64) -> bool {
106        now_ms >= self.expires_at_ms()
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn server_clock_offsets_both_directions() {
116        let mut clock = ServerClock::new();
117        assert_eq!(clock.offset_ms(), 0);
118
119        // Server is 250 ms ahead of local.
120        clock.sync(1_000, 1_250);
121        assert_eq!(clock.offset_ms(), 250);
122        assert_eq!(clock.server_time_ms(2_000), 2_250);
123
124        // Server is behind local.
125        clock.sync(5_000, 4_900);
126        assert_eq!(clock.offset_ms(), -100);
127        assert_eq!(clock.server_time_ms(5_000), 4_900);
128    }
129
130    #[test]
131    fn nonce_is_strictly_increasing_even_within_a_millisecond() {
132        let gen = NonceGenerator::new(0);
133        // Same candidate repeated: still strictly increases.
134        assert_eq!(gen.next(1_000), 1_000);
135        assert_eq!(gen.next(1_000), 1_001);
136        assert_eq!(gen.next(1_000), 1_002);
137        // A jump forward is honored.
138        assert_eq!(gen.next(5_000), 5_000);
139        // A candidate below the last is still pushed above it.
140        assert_eq!(gen.next(10), 5_001);
141    }
142
143    #[test]
144    fn nonce_starts_above_seed() {
145        let gen = NonceGenerator::new(100);
146        assert_eq!(gen.next(0), 101);
147    }
148
149    #[test]
150    fn token_ttl_expiry() {
151        let token = TokenTtl::new(1_000, 30_000);
152        assert_eq!(token.expires_at_ms(), 31_000);
153        assert!(!token.is_expired(30_999));
154        assert!(token.is_expired(31_000));
155        assert!(token.is_expired(40_000));
156    }
157}