Эх сурвалжийг харах

feat(lazer): improve time types (#2851)

* feat(lazer): improve time types (wip)

* chore: remove cadd for now

* feat(lazer): add a few more functions and costs

* test(lazer): add time tests

* chore: fix ci

* fix(lazer): is_multiple_of

* chore: bump version
Pavel Strakhov 4 сар өмнө
parent
commit
78982eb257

+ 6 - 0
.github/workflows/ci-lazer-rust.yml

@@ -41,6 +41,12 @@ jobs:
       - name: Clippy check
         run: cargo clippy -p pyth-lazer-protocol -p pyth-lazer-client -p pyth-lazer-publisher-sdk --all-targets -- --deny warnings
         if: success() || failure()
+      - name: Clippy check with mry
+        run: cargo clippy -F mry -p pyth-lazer-protocol --all-targets -- --deny warnings
+        if: success() || failure()
       - name: test
         run: cargo test -p pyth-lazer-protocol -p pyth-lazer-client -p pyth-lazer-publisher-sdk
         if: success() || failure()
+      - name: test with mry
+        run: cargo test -F mry -p pyth-lazer-protocol
+        if: success() || failure()

+ 53 - 13
Cargo.lock

@@ -737,6 +737,17 @@ dependencies = [
  "pin-project-lite",
 ]
 
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.104",
+]
+
 [[package]]
 name = "async-trait"
 version = "0.1.88"
@@ -1524,8 +1535,10 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
 dependencies = [
  "android-tzdata",
  "iana-time-zone",
+ "js-sys",
  "num-traits",
  "serde",
+ "wasm-bindgen",
  "windows-link",
 ]
 
@@ -4613,6 +4626,31 @@ dependencies = [
  "syn 2.0.104",
 ]
 
+[[package]]
+name = "mry"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2049b5892de4f1dafb01e30e42db3b42d9a78a8016bce89659322ba6255c519e"
+dependencies = [
+ "async-recursion",
+ "mry_macros",
+ "parking_lot",
+ "send_wrapper 0.6.0",
+ "serde",
+]
+
+[[package]]
+name = "mry_macros"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7d00273ad77a49702501e11864ccbd018514fcbb6028f54c14fb78a5f08d70a"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.104",
+]
+
 [[package]]
 name = "native-tls"
 version = "0.2.14"
@@ -5591,7 +5629,7 @@ dependencies = [
  "hyper 1.6.0",
  "hyper-util",
  "protobuf",
- "pyth-lazer-protocol 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)",
+ "pyth-lazer-protocol 0.8.1",
  "pyth-lazer-publisher-sdk 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)",
  "reqwest 0.12.22",
  "serde",
@@ -5621,7 +5659,7 @@ dependencies = [
  "futures-util",
  "hex",
  "libsecp256k1 0.7.2",
- "pyth-lazer-protocol 0.8.1",
+ "pyth-lazer-protocol 0.9.0",
  "serde",
  "serde_json",
  "tokio",
@@ -5633,18 +5671,14 @@ dependencies = [
 [[package]]
 name = "pyth-lazer-protocol"
 version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1258b8770756a82a39b7b02a296c10a91b93aa58c0cded47950defe4d9377644"
 dependencies = [
- "alloy-primitives 0.8.25",
  "anyhow",
- "bincode 1.3.3",
- "bs58",
  "byteorder",
  "derive_more 1.0.0",
- "ed25519-dalek 2.1.1",
- "hex",
  "humantime-serde",
  "itertools 0.13.0",
- "libsecp256k1 0.7.2",
  "protobuf",
  "rust_decimal",
  "serde",
@@ -5653,15 +5687,21 @@ dependencies = [
 
 [[package]]
 name = "pyth-lazer-protocol"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1258b8770756a82a39b7b02a296c10a91b93aa58c0cded47950defe4d9377644"
+version = "0.9.0"
 dependencies = [
+ "alloy-primitives 0.8.25",
  "anyhow",
+ "bincode 1.3.3",
+ "bs58",
  "byteorder",
+ "chrono",
  "derive_more 1.0.0",
+ "ed25519-dalek 2.1.1",
+ "hex",
  "humantime-serde",
  "itertools 0.13.0",
+ "libsecp256k1 0.7.2",
+ "mry",
  "protobuf",
  "rust_decimal",
  "serde",
@@ -5677,7 +5717,7 @@ dependencies = [
  "humantime",
  "protobuf",
  "protobuf-codegen",
- "pyth-lazer-protocol 0.8.1",
+ "pyth-lazer-protocol 0.9.0",
  "serde-value",
  "tracing",
 ]
@@ -5693,7 +5733,7 @@ dependencies = [
  "humantime",
  "protobuf",
  "protobuf-codegen",
- "pyth-lazer-protocol 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)",
+ "pyth-lazer-protocol 0.8.1",
  "serde-value",
  "tracing",
 ]

+ 10 - 3
lazer/contracts/solana/Cargo.lock

@@ -991,9 +991,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
 
 [[package]]
 name = "chrono"
-version = "0.4.38"
+version = "0.4.41"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
+checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
 dependencies = [
  "android-tzdata",
  "iana-time-zone",
@@ -1001,7 +1001,7 @@ dependencies = [
  "num-traits",
  "serde",
  "wasm-bindgen",
- "windows-targets 0.52.6",
+ "windows-link",
 ]
 
 [[package]]
@@ -3208,6 +3208,7 @@ version = "0.8.1"
 dependencies = [
  "anyhow",
  "byteorder",
+ "chrono",
  "derive_more",
  "humantime-serde",
  "itertools 0.13.0",
@@ -6359,6 +6360,12 @@ dependencies = [
  "windows-targets 0.52.6",
 ]
 
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
 [[package]]
 name = "windows-sys"
 version = "0.48.0"

+ 1 - 1
lazer/contracts/solana/programs/pyth-lazer-solana-contract/Cargo.toml

@@ -22,7 +22,7 @@ no-log-ix-name = []
 idl-build = ["anchor-lang/idl-build"]
 
 [dependencies]
-pyth-lazer-protocol = { path = "../../../../sdk/rust/protocol", version = "0.8.1" }
+pyth-lazer-protocol = { path = "../../../../sdk/rust/protocol", version = "0.9.0" }
 
 anchor-lang = "0.30.1"
 bytemuck = "1.20.0"

+ 1 - 1
lazer/publisher_sdk/rust/Cargo.toml

@@ -7,7 +7,7 @@ license = "Apache-2.0"
 repository = "https://github.com/pyth-network/pyth-crosschain"
 
 [dependencies]
-pyth-lazer-protocol = { version = "0.8.1", path = "../../sdk/rust/protocol" }
+pyth-lazer-protocol = { version = "0.9.0", path = "../../sdk/rust/protocol" }
 anyhow = "1.0.98"
 protobuf = "3.7.2"
 serde-value = "0.7.0"

+ 2 - 2
lazer/publisher_sdk/rust/src/lib.rs

@@ -7,7 +7,7 @@ use anyhow::{bail, ensure, Context};
 use humantime::format_duration;
 use protobuf::dynamic_value::{dynamic_value, DynamicValue};
 use pyth_lazer_protocol::jrpc::{FeedUpdateParams, UpdateParams};
-use pyth_lazer_protocol::router::TimestampUs;
+use pyth_lazer_protocol::time::TimestampUs;
 
 pub mod transaction_envelope {
     pub use crate::protobuf::transaction_envelope::*;
@@ -141,7 +141,7 @@ impl TryFrom<DynamicValue> for serde_value::Value {
             }
             dynamic_value::Value::TimestampValue(ts) => {
                 let ts = TimestampUs::try_from(&ts)?;
-                Ok(serde_value::Value::U64(ts.0))
+                Ok(serde_value::Value::U64(ts.as_micros()))
             }
             dynamic_value::Value::List(list) => {
                 let mut output = Vec::new();

+ 1 - 1
lazer/sdk/rust/client/Cargo.toml

@@ -6,7 +6,7 @@ description = "A Rust client for Pyth Lazer"
 license = "Apache-2.0"
 
 [dependencies]
-pyth-lazer-protocol = { path = "../protocol", version = "0.8.1" }
+pyth-lazer-protocol = { path = "../protocol", version = "0.9.0" }
 tokio = { version = "1", features = ["full"] }
 tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
 futures-util = "0.3"

+ 2 - 6
lazer/sdk/rust/client/examples/subscribe_price_feeds.rs

@@ -44,9 +44,7 @@ async fn main() -> anyhow::Result<()> {
                 delivery_format: DeliveryFormat::Json,
                 json_binary_encoding: JsonBinaryEncoding::Base64,
                 parsed: true,
-                channel: Channel::FixedRate(
-                    FixedRate::from_ms(200).expect("unsupported update rate"),
-                ),
+                channel: Channel::FixedRate(FixedRate::RATE_200_MS),
                 ignore_invalid_feed_ids: false,
             })
             .expect("invalid subscription params"),
@@ -66,9 +64,7 @@ async fn main() -> anyhow::Result<()> {
                 delivery_format: DeliveryFormat::Binary,
                 json_binary_encoding: JsonBinaryEncoding::Base64,
                 parsed: false,
-                channel: Channel::FixedRate(
-                    FixedRate::from_ms(50).expect("unsupported update rate"),
-                ),
+                channel: Channel::FixedRate(FixedRate::RATE_50_MS),
                 ignore_invalid_feed_ids: false,
             })
             .expect("invalid subscription params"),

+ 3 - 1
lazer/sdk/rust/protocol/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "pyth-lazer-protocol"
-version = "0.8.1"
+version = "0.9.0"
 edition = "2021"
 description = "Pyth Lazer SDK - protocol types."
 license = "Apache-2.0"
@@ -16,6 +16,8 @@ itertools = "0.13.0"
 rust_decimal = "1.36.0"
 protobuf = "3.7.2"
 humantime-serde = "1.1.1"
+mry = { version = "0.13.0", features = ["serde"], optional = true }
+chrono = "0.4.41"
 
 [dev-dependencies]
 bincode = "1.3.3"

+ 3 - 2
lazer/sdk/rust/protocol/src/api.rs

@@ -1,7 +1,8 @@
 use serde::{Deserialize, Serialize};
 
-use crate::router::{
-    Channel, Format, JsonBinaryEncoding, JsonUpdate, PriceFeedId, PriceFeedProperty, TimestampUs,
+use crate::{
+    router::{Channel, Format, JsonBinaryEncoding, JsonUpdate, PriceFeedId, PriceFeedProperty},
+    time::TimestampUs,
 };
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]

+ 6 - 5
lazer/sdk/rust/protocol/src/jrpc.rs

@@ -1,5 +1,6 @@
-use crate::router::{Channel, Price, PriceFeedId, Rate, TimestampUs};
+use crate::router::{Channel, Price, PriceFeedId, Rate};
 use crate::symbol_state::SymbolState;
+use crate::time::TimestampUs;
 use serde::{Deserialize, Serialize};
 use std::time::Duration;
 
@@ -157,7 +158,7 @@ mod tests {
             jsonrpc: JsonRpcVersion::V2,
             params: PushUpdate(FeedUpdateParams {
                 feed_id: PriceFeedId(1),
-                source_timestamp: TimestampUs(124214124124),
+                source_timestamp: TimestampUs::from_micros(124214124124),
                 update: UpdateParams::PriceUpdate {
                     price: Price::from_integer(1234567890, 0).unwrap(),
                     best_bid_price: Some(Price::from_integer(1234567891, 0).unwrap()),
@@ -196,7 +197,7 @@ mod tests {
             jsonrpc: JsonRpcVersion::V2,
             params: PushUpdate(FeedUpdateParams {
                 feed_id: PriceFeedId(1),
-                source_timestamp: TimestampUs(124214124124),
+                source_timestamp: TimestampUs::from_micros(124214124124),
                 update: UpdateParams::PriceUpdate {
                     price: Price::from_integer(1234567890, 0).unwrap(),
                     best_bid_price: None,
@@ -236,7 +237,7 @@ mod tests {
             jsonrpc: JsonRpcVersion::V2,
             params: PushUpdate(FeedUpdateParams {
                 feed_id: PriceFeedId(1),
-                source_timestamp: TimestampUs(124214124124),
+                source_timestamp: TimestampUs::from_micros(124214124124),
                 update: UpdateParams::FundingRateUpdate {
                     price: Some(Price::from_integer(1234567890, 0).unwrap()),
                     rate: Rate::from_integer(1234567891, 0).unwrap(),
@@ -273,7 +274,7 @@ mod tests {
             jsonrpc: JsonRpcVersion::V2,
             params: PushUpdate(FeedUpdateParams {
                 feed_id: PriceFeedId(1),
-                source_timestamp: TimestampUs(124214124124),
+                source_timestamp: TimestampUs::from_micros(124214124124),
                 update: UpdateParams::FundingRateUpdate {
                     price: None,
                     rate: Rate::from_integer(1234567891, 0).unwrap(),

+ 1 - 0
lazer/sdk/rust/protocol/src/lib.rs

@@ -11,6 +11,7 @@ mod serde_price_as_i64;
 mod serde_str;
 pub mod subscription;
 pub mod symbol_state;
+pub mod time;
 
 #[test]
 fn magics_in_big_endian() {

+ 9 - 6
lazer/sdk/rust/protocol/src/payload.rs

@@ -1,8 +1,11 @@
 //! Types representing binary encoding of signable payloads and signature envelopes.
 
 use {
-    super::router::{PriceFeedId, PriceFeedProperty, TimestampUs},
-    crate::router::{ChannelId, Price, Rate},
+    super::router::{PriceFeedId, PriceFeedProperty},
+    crate::{
+        router::{ChannelId, Price, Rate},
+        time::TimestampUs,
+    },
     anyhow::bail,
     byteorder::{ByteOrder, ReadBytesExt, WriteBytesExt, BE, LE},
     serde::{Deserialize, Serialize},
@@ -103,7 +106,7 @@ impl PayloadData {
 
     pub fn serialize<BO: ByteOrder>(&self, mut writer: impl Write) -> anyhow::Result<()> {
         writer.write_u32::<BO>(PAYLOAD_FORMAT_MAGIC)?;
-        writer.write_u64::<BO>(self.timestamp_us.0)?;
+        writer.write_u64::<BO>(self.timestamp_us.as_micros())?;
         writer.write_u8(self.channel_id.0)?;
         writer.write_u8(self.feeds.len().try_into()?)?;
         for feed in &self.feeds {
@@ -162,7 +165,7 @@ impl PayloadData {
         if magic != PAYLOAD_FORMAT_MAGIC {
             bail!("magic mismatch");
         }
-        let timestamp_us = TimestampUs(reader.read_u64::<BO>()?);
+        let timestamp_us = TimestampUs::from_micros(reader.read_u64::<BO>()?);
         let channel_id = ChannelId(reader.read_u8()?);
         let num_feeds = reader.read_u8()?;
         let mut feeds = Vec::with_capacity(num_feeds.into());
@@ -252,7 +255,7 @@ fn write_option_timestamp<BO: ByteOrder>(
     match value {
         Some(value) => {
             writer.write_u8(1)?;
-            writer.write_u64::<BO>(value.0)
+            writer.write_u64::<BO>(value.as_micros())
         }
         None => {
             writer.write_u8(0)?;
@@ -266,7 +269,7 @@ fn read_option_timestamp<BO: ByteOrder>(
 ) -> std::io::Result<Option<TimestampUs>> {
     let present = reader.read_u8()? != 0;
     if present {
-        Ok(Some(TimestampUs(reader.read_u64::<BO>()?)))
+        Ok(Some(TimestampUs::from_micros(reader.read_u64::<BO>()?)))
     } else {
         Ok(None)
     }

+ 10 - 9
lazer/sdk/rust/protocol/src/publisher.rs

@@ -3,7 +3,8 @@
 //! eliminating WebSocket overhead.
 
 use {
-    super::router::{Price, PriceFeedId, Rate, TimestampUs},
+    super::router::{Price, PriceFeedId, Rate},
+    crate::time::TimestampUs,
     derive_more::derive::From,
     serde::{Deserialize, Serialize},
 };
@@ -101,8 +102,8 @@ fn price_feed_data_v1_serde() {
 
     let expected = PriceFeedDataV1 {
         price_feed_id: PriceFeedId(1),
-        source_timestamp_us: TimestampUs(2),
-        publisher_timestamp_us: TimestampUs(3),
+        source_timestamp_us: TimestampUs::from_micros(2),
+        publisher_timestamp_us: TimestampUs::from_micros(3),
         price: Some(Price(4.try_into().unwrap())),
         best_bid_price: Some(Price(5.try_into().unwrap())),
         best_ask_price: Some(Price((2 * 256 + 6).try_into().unwrap())),
@@ -123,8 +124,8 @@ fn price_feed_data_v1_serde() {
     ];
     let expected2 = PriceFeedDataV1 {
         price_feed_id: PriceFeedId(1),
-        source_timestamp_us: TimestampUs(2),
-        publisher_timestamp_us: TimestampUs(3),
+        source_timestamp_us: TimestampUs::from_micros(2),
+        publisher_timestamp_us: TimestampUs::from_micros(3),
         price: Some(Price(4.try_into().unwrap())),
         best_bid_price: None,
         best_ask_price: None,
@@ -150,8 +151,8 @@ fn price_feed_data_v2_serde() {
 
     let expected = PriceFeedDataV2 {
         price_feed_id: PriceFeedId(1),
-        source_timestamp_us: TimestampUs(2),
-        publisher_timestamp_us: TimestampUs(3),
+        source_timestamp_us: TimestampUs::from_micros(2),
+        publisher_timestamp_us: TimestampUs::from_micros(3),
         price: Some(Price(4.try_into().unwrap())),
         best_bid_price: Some(Price(5.try_into().unwrap())),
         best_ask_price: Some(Price((2 * 256 + 6).try_into().unwrap())),
@@ -174,8 +175,8 @@ fn price_feed_data_v2_serde() {
     ];
     let expected2 = PriceFeedDataV2 {
         price_feed_id: PriceFeedId(1),
-        source_timestamp_us: TimestampUs(2),
-        publisher_timestamp_us: TimestampUs(3),
+        source_timestamp_us: TimestampUs::from_micros(2),
+        publisher_timestamp_us: TimestampUs::from_micros(3),
         price: Some(Price(4.try_into().unwrap())),
         best_bid_price: None,
         best_ask_price: None,

+ 72 - 66
lazer/sdk/rust/protocol/src/router.rs

@@ -1,18 +1,20 @@
 //! WebSocket JSON protocol types for the API the router provides to consumers and publishers.
 
-use protobuf::MessageField;
 use {
-    crate::payload::AggregatedPriceFeedData,
+    crate::{
+        payload::AggregatedPriceFeedData,
+        time::{DurationUs, TimestampUs},
+    },
     anyhow::{bail, Context},
+    derive_more::derive::From,
     itertools::Itertools,
-    protobuf::well_known_types::timestamp::Timestamp,
+    protobuf::well_known_types::duration::Duration as ProtobufDuration,
     rust_decimal::{prelude::FromPrimitive, Decimal},
     serde::{de::Error, Deserialize, Serialize},
     std::{
         fmt::Display,
         num::NonZeroI64,
         ops::{Add, Deref, DerefMut, Div, Sub},
-        time::{SystemTime, UNIX_EPOCH},
     },
 };
 
@@ -25,53 +27,6 @@ pub struct PriceFeedId(pub u32);
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct ChannelId(pub u8);
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
-pub struct TimestampUs(pub u64);
-
-impl TryFrom<&Timestamp> for TimestampUs {
-    type Error = anyhow::Error;
-
-    fn try_from(timestamp: &Timestamp) -> anyhow::Result<Self> {
-        let seconds_in_micros: u64 = (timestamp.seconds * 1_000_000).try_into()?;
-        let nanos_in_micros: u64 = (timestamp.nanos / 1_000).try_into()?;
-        Ok(TimestampUs(seconds_in_micros + nanos_in_micros))
-    }
-}
-
-impl From<TimestampUs> for Timestamp {
-    fn from(value: TimestampUs) -> Self {
-        Timestamp {
-            // u64 to i64 after this division can never overflow because the value cannot be too big
-            #[allow(clippy::cast_possible_wrap)]
-            seconds: (value.0 / 1_000_000) as i64,
-            nanos: (value.0 % 1_000_000) as i32 * 1000,
-            special_fields: Default::default(),
-        }
-    }
-}
-
-impl From<TimestampUs> for MessageField<Timestamp> {
-    fn from(value: TimestampUs) -> Self {
-        MessageField::some(value.into())
-    }
-}
-
-impl TimestampUs {
-    pub fn now() -> Self {
-        let value = SystemTime::now()
-            .duration_since(UNIX_EPOCH)
-            .expect("invalid system time")
-            .as_micros()
-            .try_into()
-            .expect("invalid system time");
-        Self(value)
-    }
-
-    pub fn saturating_us_since(self, other: Self) -> u64 {
-        self.0.saturating_sub(other.0)
-    }
-}
-
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 #[repr(transparent)]
 pub struct Rate(pub i64);
@@ -244,7 +199,7 @@ pub enum JsonBinaryEncoding {
     Hex,
 }
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, From)]
 pub enum Channel {
     FixedRate(FixedRate),
 }
@@ -259,7 +214,10 @@ impl Serialize for Channel {
                 if *fixed_rate == FixedRate::MIN {
                     return serializer.serialize_str("real_time");
                 }
-                serializer.serialize_str(&format!("fixed_rate@{}ms", fixed_rate.value_ms()))
+                serializer.serialize_str(&format!(
+                    "fixed_rate@{}ms",
+                    fixed_rate.duration().as_millis()
+                ))
             }
         }
     }
@@ -278,7 +236,7 @@ impl Display for Channel {
         match self {
             Channel::FixedRate(fixed_rate) => match *fixed_rate {
                 FixedRate::MIN => write!(f, "real_time"),
-                rate => write!(f, "fixed_rate@{}ms", rate.value_ms()),
+                rate => write!(f, "fixed_rate@{}ms", rate.duration().as_millis()),
             },
         }
     }
@@ -287,7 +245,7 @@ impl Display for Channel {
 impl Channel {
     pub fn id(&self) -> ChannelId {
         match self {
-            Channel::FixedRate(fixed_rate) => match fixed_rate.value_ms() {
+            Channel::FixedRate(fixed_rate) => match fixed_rate.duration().as_millis() {
                 1 => channel_ids::FIXED_RATE_1,
                 50 => channel_ids::FIXED_RATE_50,
                 200 => channel_ids::FIXED_RATE_200,
@@ -309,7 +267,7 @@ fn parse_channel(value: &str) -> Option<Channel> {
         Some(Channel::FixedRate(FixedRate::MIN))
     } else if let Some(rest) = value.strip_prefix("fixed_rate@") {
         let ms_value = rest.strip_suffix("ms")?;
-        Some(Channel::FixedRate(FixedRate::from_ms(
+        Some(Channel::FixedRate(FixedRate::from_millis(
             ms_value.parse().ok()?,
         )?))
     } else {
@@ -329,27 +287,75 @@ impl<'de> Deserialize<'de> for Channel {
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
 pub struct FixedRate {
-    ms: u32,
+    rate: DurationUs,
 }
 
 impl FixedRate {
+    pub const RATE_1_MS: Self = Self {
+        rate: DurationUs::from_millis_u32(1),
+    };
+    pub const RATE_50_MS: Self = Self {
+        rate: DurationUs::from_millis_u32(50),
+    };
+    pub const RATE_200_MS: Self = Self {
+        rate: DurationUs::from_millis_u32(200),
+    };
+
     // Assumptions (tested below):
     // - Values are sorted.
     // - 1 second contains a whole number of each interval.
     // - all intervals are divisable by the smallest interval.
-    pub const ALL: [Self; 3] = [Self { ms: 1 }, Self { ms: 50 }, Self { ms: 200 }];
+    pub const ALL: [Self; 3] = [Self::RATE_1_MS, Self::RATE_50_MS, Self::RATE_200_MS];
     pub const MIN: Self = Self::ALL[0];
 
-    pub fn from_ms(value: u32) -> Option<Self> {
-        Self::ALL.into_iter().find(|v| v.ms == value)
+    pub fn from_millis(millis: u32) -> Option<Self> {
+        Self::ALL
+            .into_iter()
+            .find(|v| v.rate.as_millis() == u64::from(millis))
+    }
+
+    pub fn duration(self) -> DurationUs {
+        self.rate
+    }
+}
+
+impl TryFrom<DurationUs> for FixedRate {
+    type Error = anyhow::Error;
+
+    fn try_from(value: DurationUs) -> Result<Self, Self::Error> {
+        Self::ALL
+            .into_iter()
+            .find(|v| v.rate == value)
+            .with_context(|| format!("unsupported rate: {value:?}"))
+    }
+}
+
+impl TryFrom<&ProtobufDuration> for FixedRate {
+    type Error = anyhow::Error;
+
+    fn try_from(value: &ProtobufDuration) -> Result<Self, Self::Error> {
+        let duration = DurationUs::try_from(value)?;
+        Self::try_from(duration)
+    }
+}
+
+impl TryFrom<ProtobufDuration> for FixedRate {
+    type Error = anyhow::Error;
+
+    fn try_from(duration: ProtobufDuration) -> anyhow::Result<Self> {
+        TryFrom::<&ProtobufDuration>::try_from(&duration)
     }
+}
 
-    pub fn value_ms(self) -> u32 {
-        self.ms
+impl From<FixedRate> for DurationUs {
+    fn from(value: FixedRate) -> Self {
+        value.rate
     }
+}
 
-    pub fn value_us(self) -> u64 {
-        (self.ms * 1000).into()
+impl From<FixedRate> for ProtobufDuration {
+    fn from(value: FixedRate) -> Self {
+        value.rate.into()
     }
 }
 
@@ -361,12 +367,12 @@ fn fixed_rate_values() {
     );
     for value in FixedRate::ALL {
         assert_eq!(
-            1000 % value.ms,
+            1_000_000 % value.duration().as_micros(),
             0,
             "1 s must contain whole number of intervals"
         );
         assert_eq!(
-            value.value_us() % FixedRate::MIN.value_us(),
+            value.duration().as_micros() % FixedRate::MIN.duration().as_micros(),
             0,
             "the interval's borders must be a subset of the minimal interval's borders"
         );

+ 4 - 4
lazer/sdk/rust/protocol/src/serde_str.rs

@@ -31,7 +31,7 @@ pub mod option_price {
 
 pub mod timestamp {
     use {
-        crate::router::TimestampUs,
+        crate::time::TimestampUs,
         serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer},
     };
 
@@ -39,15 +39,15 @@ pub mod timestamp {
     where
         S: Serializer,
     {
-        value.0.to_string().serialize(serializer)
+        value.as_micros().to_string().serialize(serializer)
     }
 
     pub fn deserialize<'de, D>(deserializer: D) -> Result<TimestampUs, D::Error>
     where
         D: Deserializer<'de>,
     {
-        let value = <&str>::deserialize(deserializer)?;
+        let value = String::deserialize(deserializer)?;
         let value: u64 = value.parse().map_err(D::Error::custom)?;
-        Ok(TimestampUs(value))
+        Ok(TimestampUs::from_micros(value))
     }
 }

+ 488 - 0
lazer/sdk/rust/protocol/src/time.rs

@@ -0,0 +1,488 @@
+#[cfg(test)]
+mod tests;
+
+use {
+    anyhow::Context,
+    protobuf::{
+        well_known_types::{
+            duration::Duration as ProtobufDuration, timestamp::Timestamp as ProtobufTimestamp,
+        },
+        MessageField,
+    },
+    serde::{Deserialize, Serialize},
+    std::time::{Duration, SystemTime},
+};
+
+/// Unix timestamp with microsecond resolution.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+#[repr(transparent)]
+pub struct TimestampUs(u64);
+
+#[cfg_attr(feature = "mry", mry::mry)]
+impl TimestampUs {
+    pub fn now() -> Self {
+        SystemTime::now().try_into().expect("invalid system time")
+    }
+}
+
+impl TimestampUs {
+    pub const UNIX_EPOCH: Self = Self(0);
+    pub const MAX: Self = Self(u64::MAX);
+
+    #[inline]
+    pub const fn from_micros(micros: u64) -> Self {
+        Self(micros)
+    }
+
+    #[inline]
+    pub const fn as_micros(self) -> u64 {
+        self.0
+    }
+
+    #[inline]
+    pub fn as_nanos(self) -> u128 {
+        // never overflows
+        u128::from(self.0) * 1000
+    }
+
+    #[inline]
+    pub fn as_nanos_i128(self) -> i128 {
+        // never overflows
+        i128::from(self.0) * 1000
+    }
+
+    #[inline]
+    pub fn from_nanos(nanos: u128) -> anyhow::Result<Self> {
+        let micros = nanos
+            .checked_div(1000)
+            .context("nanos.checked_div(1000) failed")?;
+        Ok(Self::from_micros(micros.try_into()?))
+    }
+
+    #[inline]
+    pub fn from_nanos_i128(nanos: i128) -> anyhow::Result<Self> {
+        let micros = nanos
+            .checked_div(1000)
+            .context("nanos.checked_div(1000) failed")?;
+        Ok(Self::from_micros(micros.try_into()?))
+    }
+
+    #[inline]
+    pub fn as_millis(self) -> u64 {
+        self.0 / 1000
+    }
+
+    #[inline]
+    pub fn from_millis(millis: u64) -> anyhow::Result<Self> {
+        let micros = millis
+            .checked_mul(1000)
+            .context("millis.checked_mul(1000) failed")?;
+        Ok(Self::from_micros(micros))
+    }
+
+    #[inline]
+    pub fn as_secs(self) -> u64 {
+        self.0 / 1_000_000
+    }
+
+    #[inline]
+    pub fn from_secs(secs: u64) -> anyhow::Result<Self> {
+        let micros = secs
+            .checked_mul(1_000_000)
+            .context("secs.checked_mul(1_000_000) failed")?;
+        Ok(Self::from_micros(micros))
+    }
+
+    #[inline]
+    pub fn duration_since(self, other: Self) -> anyhow::Result<DurationUs> {
+        Ok(DurationUs(
+            self.0
+                .checked_sub(other.0)
+                .context("timestamp.checked_sub(duration) failed")?,
+        ))
+    }
+
+    #[inline]
+    pub fn saturating_duration_since(self, other: Self) -> DurationUs {
+        DurationUs(self.0.saturating_sub(other.0))
+    }
+
+    #[inline]
+    pub fn elapsed(self) -> anyhow::Result<DurationUs> {
+        Self::now().duration_since(self)
+    }
+
+    #[inline]
+    pub fn saturating_elapsed(self) -> DurationUs {
+        Self::now().saturating_duration_since(self)
+    }
+
+    #[inline]
+    pub fn saturating_add(self, duration: DurationUs) -> TimestampUs {
+        TimestampUs(self.0.saturating_add(duration.0))
+    }
+
+    #[inline]
+    pub fn saturating_sub(self, duration: DurationUs) -> TimestampUs {
+        TimestampUs(self.0.saturating_sub(duration.0))
+    }
+
+    #[inline]
+    pub fn is_multiple_of(self, duration: DurationUs) -> bool {
+        match self.0.checked_rem(duration.0) {
+            Some(rem) => rem == 0,
+            None => true,
+        }
+    }
+
+    /// Calculates the smallest value greater than or equal to self that is a multiple of `duration`.
+    #[inline]
+    pub fn next_multiple_of(self, duration: DurationUs) -> anyhow::Result<TimestampUs> {
+        Ok(TimestampUs(
+            self.0
+                .checked_next_multiple_of(duration.0)
+                .context("checked_next_multiple_of failed")?,
+        ))
+    }
+
+    /// Calculates the smallest value less than or equal to self that is a multiple of `duration`.
+    #[inline]
+    pub fn previous_multiple_of(self, duration: DurationUs) -> anyhow::Result<TimestampUs> {
+        Ok(TimestampUs(
+            self.0
+                .checked_div(duration.0)
+                .context("checked_div failed")?
+                .checked_mul(duration.0)
+                .context("checked_mul failed")?,
+        ))
+    }
+
+    #[inline]
+    pub fn checked_add(self, duration: DurationUs) -> anyhow::Result<Self> {
+        Ok(TimestampUs(
+            self.0
+                .checked_add(duration.0)
+                .context("checked_add failed")?,
+        ))
+    }
+
+    #[inline]
+    pub fn checked_sub(self, duration: DurationUs) -> anyhow::Result<Self> {
+        Ok(TimestampUs(
+            self.0
+                .checked_sub(duration.0)
+                .context("checked_sub failed")?,
+        ))
+    }
+}
+
+impl TryFrom<ProtobufTimestamp> for TimestampUs {
+    type Error = anyhow::Error;
+
+    #[inline]
+    fn try_from(timestamp: ProtobufTimestamp) -> anyhow::Result<Self> {
+        TryFrom::<&ProtobufTimestamp>::try_from(&timestamp)
+    }
+}
+
+impl TryFrom<&ProtobufTimestamp> for TimestampUs {
+    type Error = anyhow::Error;
+
+    fn try_from(timestamp: &ProtobufTimestamp) -> anyhow::Result<Self> {
+        let seconds_in_micros: u64 = timestamp
+            .seconds
+            .checked_mul(1_000_000)
+            .context("checked_mul failed")?
+            .try_into()?;
+        let nanos_in_micros: u64 = timestamp
+            .nanos
+            .checked_div(1_000)
+            .context("checked_div failed")?
+            .try_into()?;
+        Ok(TimestampUs(
+            seconds_in_micros
+                .checked_add(nanos_in_micros)
+                .context("checked_add failed")?,
+        ))
+    }
+}
+
+impl From<TimestampUs> for ProtobufTimestamp {
+    fn from(timestamp: TimestampUs) -> Self {
+        // u64 to i64 after this division can never overflow because the value cannot be too big
+        ProtobufTimestamp {
+            #[allow(clippy::cast_possible_wrap)]
+            seconds: (timestamp.0 / 1_000_000) as i64,
+            // never fails, never overflows
+            nanos: (timestamp.0 % 1_000_000) as i32 * 1000,
+            special_fields: Default::default(),
+        }
+    }
+}
+
+impl From<TimestampUs> for MessageField<ProtobufTimestamp> {
+    #[inline]
+    fn from(value: TimestampUs) -> Self {
+        MessageField::some(value.into())
+    }
+}
+
+impl TryFrom<SystemTime> for TimestampUs {
+    type Error = anyhow::Error;
+
+    fn try_from(value: SystemTime) -> Result<Self, Self::Error> {
+        let value = value
+            .duration_since(SystemTime::UNIX_EPOCH)
+            .context("invalid system time")?
+            .as_micros()
+            .try_into()?;
+        Ok(Self(value))
+    }
+}
+
+impl TryFrom<TimestampUs> for SystemTime {
+    type Error = anyhow::Error;
+
+    fn try_from(value: TimestampUs) -> Result<Self, Self::Error> {
+        SystemTime::UNIX_EPOCH
+            .checked_add(Duration::from_micros(value.as_micros()))
+            .context("checked_add failed")
+    }
+}
+
+impl TryFrom<&chrono::DateTime<chrono::Utc>> for TimestampUs {
+    type Error = anyhow::Error;
+
+    #[inline]
+    fn try_from(value: &chrono::DateTime<chrono::Utc>) -> Result<Self, Self::Error> {
+        Ok(Self(value.timestamp_micros().try_into()?))
+    }
+}
+
+impl TryFrom<chrono::DateTime<chrono::Utc>> for TimestampUs {
+    type Error = anyhow::Error;
+
+    #[inline]
+    fn try_from(value: chrono::DateTime<chrono::Utc>) -> Result<Self, Self::Error> {
+        TryFrom::<&chrono::DateTime<chrono::Utc>>::try_from(&value)
+    }
+}
+
+impl TryFrom<TimestampUs> for chrono::DateTime<chrono::Utc> {
+    type Error = anyhow::Error;
+
+    #[inline]
+    fn try_from(value: TimestampUs) -> Result<Self, Self::Error> {
+        chrono::DateTime::<chrono::Utc>::from_timestamp_micros(value.as_micros().try_into()?)
+            .with_context(|| format!("cannot convert timestamp to datetime: {value:?}"))
+    }
+}
+
+/// Non-negative duration with microsecond resolution.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct DurationUs(u64);
+
+impl DurationUs {
+    pub const ZERO: Self = Self(0);
+
+    #[inline]
+    pub const fn from_micros(micros: u64) -> Self {
+        Self(micros)
+    }
+
+    #[inline]
+    pub const fn as_micros(self) -> u64 {
+        self.0
+    }
+
+    #[inline]
+    pub fn as_nanos(self) -> u128 {
+        // never overflows
+        u128::from(self.0) * 1000
+    }
+
+    #[inline]
+    pub fn as_nanos_i128(self) -> i128 {
+        // never overflows
+        i128::from(self.0) * 1000
+    }
+
+    #[inline]
+    pub fn from_nanos(nanos: u128) -> anyhow::Result<Self> {
+        let micros = nanos.checked_div(1000).context("checked_div failed")?;
+        Ok(Self::from_micros(micros.try_into()?))
+    }
+
+    #[inline]
+    pub fn as_millis(self) -> u64 {
+        self.0 / 1000
+    }
+
+    #[inline]
+    pub const fn from_millis_u32(millis: u32) -> Self {
+        // never overflows
+        Self((millis as u64) * 1_000)
+    }
+
+    #[inline]
+    pub fn from_millis(millis: u64) -> anyhow::Result<Self> {
+        let micros = millis
+            .checked_mul(1000)
+            .context("millis.checked_mul(1000) failed")?;
+        Ok(Self::from_micros(micros))
+    }
+
+    #[inline]
+    pub fn as_secs(self) -> u64 {
+        self.0 / 1_000_000
+    }
+
+    #[inline]
+    pub const fn from_secs_u32(secs: u32) -> Self {
+        // never overflows
+        Self((secs as u64) * 1_000_000)
+    }
+
+    #[inline]
+    pub fn from_secs(secs: u64) -> anyhow::Result<Self> {
+        let micros = secs
+            .checked_mul(1_000_000)
+            .context("secs.checked_mul(1_000_000) failed")?;
+        Ok(Self::from_micros(micros))
+    }
+
+    #[inline]
+    pub fn from_days_u16(days: u16) -> Self {
+        // never overflows
+        Self((days as u64) * 24 * 3600 * 1_000_000)
+    }
+
+    #[inline]
+    pub fn is_multiple_of(self, other: DurationUs) -> bool {
+        match self.0.checked_rem(other.0) {
+            Some(rem) => rem == 0,
+            None => true,
+        }
+    }
+
+    #[inline]
+    pub const fn is_zero(self) -> bool {
+        self.0 == 0
+    }
+
+    #[inline]
+    pub const fn is_positive(self) -> bool {
+        self.0 > 0
+    }
+
+    #[inline]
+    pub fn checked_add(self, other: DurationUs) -> anyhow::Result<Self> {
+        Ok(DurationUs(
+            self.0.checked_add(other.0).context("checked_add failed")?,
+        ))
+    }
+
+    #[inline]
+    pub fn checked_sub(self, other: DurationUs) -> anyhow::Result<Self> {
+        Ok(DurationUs(
+            self.0.checked_sub(other.0).context("checked_sub failed")?,
+        ))
+    }
+
+    #[inline]
+    pub fn checked_mul(self, n: u64) -> anyhow::Result<DurationUs> {
+        Ok(DurationUs(
+            self.0.checked_mul(n).context("checked_mul failed")?,
+        ))
+    }
+
+    #[inline]
+    pub fn checked_div(self, n: u64) -> anyhow::Result<DurationUs> {
+        Ok(DurationUs(
+            self.0.checked_div(n).context("checked_div failed")?,
+        ))
+    }
+}
+
+impl From<DurationUs> for Duration {
+    #[inline]
+    fn from(value: DurationUs) -> Self {
+        Duration::from_micros(value.as_micros())
+    }
+}
+
+impl TryFrom<Duration> for DurationUs {
+    type Error = anyhow::Error;
+
+    #[inline]
+    fn try_from(value: Duration) -> Result<Self, Self::Error> {
+        Ok(Self(value.as_micros().try_into()?))
+    }
+}
+
+impl TryFrom<ProtobufDuration> for DurationUs {
+    type Error = anyhow::Error;
+
+    #[inline]
+    fn try_from(duration: ProtobufDuration) -> anyhow::Result<Self> {
+        TryFrom::<&ProtobufDuration>::try_from(&duration)
+    }
+}
+
+impl TryFrom<&ProtobufDuration> for DurationUs {
+    type Error = anyhow::Error;
+
+    fn try_from(duration: &ProtobufDuration) -> anyhow::Result<Self> {
+        let seconds_in_micros: u64 = duration
+            .seconds
+            .checked_mul(1_000_000)
+            .context("checked_mul failed")?
+            .try_into()?;
+        let nanos_in_micros: u64 = duration
+            .nanos
+            .checked_div(1_000)
+            .context("nanos.checked_div(1_000) failed")?
+            .try_into()?;
+        Ok(DurationUs(
+            seconds_in_micros
+                .checked_add(nanos_in_micros)
+                .context("checked_add failed")?,
+        ))
+    }
+}
+
+impl From<DurationUs> for ProtobufDuration {
+    fn from(duration: DurationUs) -> Self {
+        ProtobufDuration {
+            // u64 to i64 after this division can never overflow because the value cannot be too big
+            #[allow(clippy::cast_possible_wrap)]
+            seconds: (duration.0 / 1_000_000) as i64,
+            // never fails, never overflows
+            nanos: (duration.0 % 1_000_000) as i32 * 1000,
+            special_fields: Default::default(),
+        }
+    }
+}
+
+pub mod duration_us_serde_humantime {
+    use std::time::Duration;
+
+    use serde::{de::Error, Deserialize, Serialize};
+
+    use crate::time::DurationUs;
+
+    pub fn serialize<S>(value: &DurationUs, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        humantime_serde::Serde::from(Duration::from(*value)).serialize(serializer)
+    }
+
+    pub fn deserialize<'de, D>(deserializer: D) -> Result<DurationUs, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let value = humantime_serde::Serde::<Duration>::deserialize(deserializer)?;
+        value.into_inner().try_into().map_err(D::Error::custom)
+    }
+}

+ 330 - 0
lazer/sdk/rust/protocol/src/time/tests.rs

@@ -0,0 +1,330 @@
+use super::*;
+
+type ChronoUtcDateTime = chrono::DateTime<chrono::Utc>;
+
+#[test]
+fn timestamp_constructors() {
+    assert!(TimestampUs::now() > TimestampUs::UNIX_EPOCH);
+    assert!(TimestampUs::now() < TimestampUs::MAX);
+
+    assert_eq!(TimestampUs::from_micros(12345).as_micros(), 12345);
+    assert_eq!(TimestampUs::from_micros(12345).as_nanos(), 12345000);
+    assert_eq!(TimestampUs::from_micros(12345).as_millis(), 12);
+    assert_eq!(TimestampUs::from_micros(12345).as_secs(), 0);
+
+    assert_eq!(TimestampUs::from_micros(123456789).as_millis(), 123456);
+    assert_eq!(TimestampUs::from_micros(123456789).as_secs(), 123);
+
+    assert_eq!(
+        TimestampUs::from_nanos(1234567890).unwrap().as_nanos(),
+        1234567000
+    );
+    assert_eq!(
+        TimestampUs::from_nanos(1234567890).unwrap().as_nanos_i128(),
+        1234567000
+    );
+
+    assert_eq!(TimestampUs::from_millis(25).unwrap().as_millis(), 25);
+    assert_eq!(TimestampUs::from_millis(25).unwrap().as_micros(), 25000);
+
+    assert_eq!(TimestampUs::from_secs(25).unwrap().as_secs(), 25);
+    assert_eq!(TimestampUs::from_secs(25).unwrap().as_millis(), 25000);
+    assert_eq!(TimestampUs::from_secs(25).unwrap().as_micros(), 25000000);
+
+    TimestampUs::from_nanos(u128::from(u64::MAX) * 1000 + 5000).unwrap_err();
+    TimestampUs::from_millis(5_000_000_000_000_000_000).unwrap_err();
+    TimestampUs::from_secs(5_000_000_000_000_000).unwrap_err();
+}
+
+#[test]
+fn duration_constructors() {
+    assert_eq!(DurationUs::from_micros(12345).as_micros(), 12345);
+    assert_eq!(DurationUs::from_micros(12345).as_nanos(), 12345000);
+    assert_eq!(DurationUs::from_micros(12345).as_millis(), 12);
+    assert_eq!(DurationUs::from_micros(12345).as_secs(), 0);
+
+    assert_eq!(DurationUs::from_micros(123456789).as_millis(), 123456);
+    assert_eq!(DurationUs::from_micros(123456789).as_secs(), 123);
+
+    assert_eq!(
+        DurationUs::from_nanos(1234567890).unwrap().as_nanos(),
+        1234567000
+    );
+    assert_eq!(
+        DurationUs::from_nanos(1234567890).unwrap().as_nanos_i128(),
+        1234567000
+    );
+
+    assert_eq!(DurationUs::from_millis(25).unwrap().as_millis(), 25);
+    assert_eq!(DurationUs::from_millis(25).unwrap().as_micros(), 25000);
+
+    assert_eq!(DurationUs::from_secs(25).unwrap().as_secs(), 25);
+    assert_eq!(DurationUs::from_secs(25).unwrap().as_millis(), 25000);
+    assert_eq!(DurationUs::from_secs(25).unwrap().as_micros(), 25000000);
+
+    DurationUs::from_nanos(u128::from(u64::MAX) * 1000 + 5000).unwrap_err();
+    DurationUs::from_millis(5_000_000_000_000_000_000).unwrap_err();
+    DurationUs::from_secs(5_000_000_000_000_000).unwrap_err();
+
+    assert_eq!(DurationUs::from_millis_u32(42).as_micros(), 42_000);
+    assert_eq!(DurationUs::from_secs_u32(42).as_micros(), 42_000_000);
+    assert_eq!(DurationUs::from_days_u16(42).as_micros(), 3_628_800_000_000);
+
+    assert_eq!(
+        DurationUs::from_millis_u32(u32::MAX).as_micros(),
+        4_294_967_295_000
+    );
+    assert_eq!(
+        DurationUs::from_secs_u32(u32::MAX).as_micros(),
+        4_294_967_295_000_000
+    );
+    assert_eq!(
+        DurationUs::from_days_u16(u16::MAX).as_micros(),
+        5_662_224_000_000_000
+    );
+}
+
+#[test]
+#[allow(clippy::bool_assert_comparison)]
+fn timestamp_ops() {
+    assert_eq!(
+        TimestampUs::from_micros(123)
+            .checked_sub(DurationUs::from_micros(23))
+            .unwrap(),
+        TimestampUs::from_micros(100)
+    );
+    TimestampUs::from_micros(123)
+        .checked_sub(DurationUs::from_micros(223))
+        .unwrap_err();
+
+    assert_eq!(
+        TimestampUs::from_micros(123)
+            .checked_add(DurationUs::from_micros(23))
+            .unwrap(),
+        TimestampUs::from_micros(146)
+    );
+    TimestampUs::from_micros(u64::MAX - 5)
+        .checked_add(DurationUs::from_micros(223))
+        .unwrap_err();
+
+    assert_eq!(
+        TimestampUs::from_micros(123)
+            .duration_since(TimestampUs::from_micros(23))
+            .unwrap(),
+        DurationUs::from_micros(100)
+    );
+    TimestampUs::from_micros(123)
+        .duration_since(TimestampUs::from_micros(223))
+        .unwrap_err();
+
+    assert_eq!(
+        TimestampUs::from_micros(123).saturating_duration_since(TimestampUs::from_micros(23)),
+        DurationUs::from_micros(100)
+    );
+    assert_eq!(
+        TimestampUs::from_micros(123).saturating_duration_since(TimestampUs::from_micros(223)),
+        DurationUs::ZERO
+    );
+
+    assert_eq!(
+        TimestampUs::from_micros(123).saturating_add(DurationUs::from_micros(100)),
+        TimestampUs::from_micros(223)
+    );
+    assert_eq!(
+        TimestampUs::from_micros(u64::MAX - 100).saturating_add(DurationUs::from_micros(200)),
+        TimestampUs::from_micros(u64::MAX)
+    );
+    assert_eq!(
+        TimestampUs::from_micros(123).saturating_sub(DurationUs::from_micros(100)),
+        TimestampUs::from_micros(23)
+    );
+    assert_eq!(
+        TimestampUs::from_micros(123).saturating_sub(DurationUs::from_micros(200)),
+        TimestampUs::from_micros(0)
+    );
+    assert_eq!(
+        TimestampUs::from_micros(123).is_multiple_of(DurationUs::from_micros(200)),
+        false
+    );
+    assert_eq!(
+        TimestampUs::from_micros(400).is_multiple_of(DurationUs::from_micros(200)),
+        true
+    );
+    assert_eq!(
+        TimestampUs::from_micros(400).is_multiple_of(DurationUs::from_micros(0)),
+        true
+    );
+    assert_eq!(
+        TimestampUs::from_micros(400)
+            .next_multiple_of(DurationUs::from_micros(200))
+            .unwrap(),
+        TimestampUs::from_micros(400)
+    );
+    assert_eq!(
+        TimestampUs::from_micros(400)
+            .previous_multiple_of(DurationUs::from_micros(200))
+            .unwrap(),
+        TimestampUs::from_micros(400)
+    );
+    assert_eq!(
+        TimestampUs::from_micros(678)
+            .next_multiple_of(DurationUs::from_micros(200))
+            .unwrap(),
+        TimestampUs::from_micros(800)
+    );
+    assert_eq!(
+        TimestampUs::from_micros(678)
+            .previous_multiple_of(DurationUs::from_micros(200))
+            .unwrap(),
+        TimestampUs::from_micros(600)
+    );
+    TimestampUs::from_micros(678)
+        .previous_multiple_of(DurationUs::from_micros(0))
+        .unwrap_err();
+    TimestampUs::from_micros(678)
+        .next_multiple_of(DurationUs::from_micros(0))
+        .unwrap_err();
+    TimestampUs::from_micros(u64::MAX - 5)
+        .next_multiple_of(DurationUs::from_micros(1000))
+        .unwrap_err();
+}
+
+#[test]
+#[allow(clippy::bool_assert_comparison)]
+fn duration_ops() {
+    assert_eq!(
+        DurationUs::from_micros(400).is_multiple_of(DurationUs::from_micros(200)),
+        true
+    );
+    assert_eq!(
+        DurationUs::from_micros(400).is_multiple_of(DurationUs::from_micros(300)),
+        false
+    );
+    assert_eq!(
+        DurationUs::from_micros(400).is_multiple_of(DurationUs::from_micros(0)),
+        true
+    );
+
+    assert_eq!(
+        DurationUs::from_micros(123)
+            .checked_add(DurationUs::from_micros(100))
+            .unwrap(),
+        DurationUs::from_micros(223)
+    );
+    DurationUs::from_micros(u64::MAX - 5)
+        .checked_add(DurationUs::from_micros(100))
+        .unwrap_err();
+
+    assert_eq!(
+        DurationUs::from_micros(123)
+            .checked_sub(DurationUs::from_micros(100))
+            .unwrap(),
+        DurationUs::from_micros(23)
+    );
+    DurationUs::from_micros(123)
+        .checked_sub(DurationUs::from_micros(200))
+        .unwrap_err();
+
+    assert_eq!(
+        DurationUs::from_micros(123).checked_mul(100).unwrap(),
+        DurationUs::from_micros(12300)
+    );
+    DurationUs::from_micros(u64::MAX - 5)
+        .checked_mul(100)
+        .unwrap_err();
+    assert_eq!(
+        DurationUs::from_micros(123).checked_div(100).unwrap(),
+        DurationUs::from_micros(1)
+    );
+    assert_eq!(
+        DurationUs::from_micros(12300).checked_div(100).unwrap(),
+        DurationUs::from_micros(123)
+    );
+    DurationUs::from_micros(123).checked_div(0).unwrap_err();
+
+    assert!(DurationUs::ZERO.is_zero());
+    assert!(!DurationUs::ZERO.is_positive());
+
+    assert!(DurationUs::from_micros(5).is_positive());
+    assert!(!DurationUs::from_micros(5).is_zero());
+}
+
+#[test]
+fn timestamp_conversions() {
+    let system_time = SystemTime::UNIX_EPOCH + Duration::from_micros(3_456_789_123_456_789);
+    let ts = TimestampUs::try_from(system_time).unwrap();
+    assert_eq!(ts, TimestampUs::from_micros(3_456_789_123_456_789));
+    assert_eq!(SystemTime::try_from(ts).unwrap(), system_time);
+
+    let proto_ts = ProtobufTimestamp::from(ts);
+    assert_eq!(proto_ts.seconds, 3_456_789_123);
+    assert_eq!(proto_ts.nanos, 456_789_000);
+    assert_eq!(TimestampUs::try_from(&proto_ts).unwrap(), ts);
+    assert_eq!(TimestampUs::try_from(proto_ts).unwrap(), ts);
+
+    let chrono_dt: ChronoUtcDateTime = "2079-07-17T03:12:03.456789Z".parse().unwrap();
+    assert_eq!(ChronoUtcDateTime::try_from(ts).unwrap(), chrono_dt);
+    assert_eq!(TimestampUs::try_from(chrono_dt).unwrap(), ts);
+}
+
+#[test]
+fn duration_conversions() {
+    let duration = DurationUs::from_micros(123_456_789);
+    let std_duration = Duration::from(duration);
+    assert_eq!(format!("{std_duration:?}"), "123.456789s");
+    assert_eq!(DurationUs::try_from(std_duration).unwrap(), duration);
+
+    let proto_duration = ProtobufDuration::from(duration);
+    assert_eq!(proto_duration.seconds, 123);
+    assert_eq!(proto_duration.nanos, 456_789_000);
+    assert_eq!(DurationUs::try_from(proto_duration).unwrap(), duration);
+}
+
+#[derive(Debug, PartialEq, Deserialize, Serialize)]
+struct Test1 {
+    t1: TimestampUs,
+    d1: DurationUs,
+    #[serde(with = "super::duration_us_serde_humantime")]
+    d2: DurationUs,
+}
+
+#[test]
+fn time_serde() {
+    let test1 = Test1 {
+        t1: TimestampUs::from_micros(123456789),
+        d1: DurationUs::from_micros(123456789),
+        d2: DurationUs::from_micros(123456789),
+    };
+
+    let json = serde_json::to_string(&test1).unwrap();
+    assert_eq!(
+        json,
+        r#"{"t1":123456789,"d1":123456789,"d2":"2m 3s 456ms 789us"}"#
+    );
+    assert_eq!(serde_json::from_str::<Test1>(&json).unwrap(), test1);
+}
+
+#[cfg(feature = "mry")]
+#[test]
+#[mry::lock(TimestampUs::now)]
+fn now_tests() {
+    use std::sync::atomic::{AtomicU64, Ordering};
+    use std::sync::Arc;
+
+    let now = Arc::new(AtomicU64::new(42));
+    let now2 = Arc::clone(&now);
+    TimestampUs::mock_now()
+        .returns_with(move || TimestampUs::from_micros(now2.load(Ordering::Relaxed)));
+
+    assert_eq!(TimestampUs::now().as_micros(), 42);
+
+    now.store(45, Ordering::Relaxed);
+    let s = TimestampUs::now();
+    now.store(95, Ordering::Relaxed);
+    assert_eq!(s.elapsed().unwrap(), DurationUs::from_micros(50));
+    assert_eq!(s.saturating_elapsed(), DurationUs::from_micros(50));
+
+    now.store(35, Ordering::Relaxed);
+    s.elapsed().unwrap_err();
+    assert_eq!(s.saturating_elapsed(), DurationUs::ZERO);
+}