Skip to main content

rialo_types/
block.rs

1// Copyright (c) Subzero Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Strongly-typed chain position.
5//!
6//! A monotonically increasing block height. In this system the consensus
7//! *slot*, the report *round*, and the ledger *block height* are the same
8//! monotonic quantity (`round == clock.slot == block_height`), historically all
9//! carried as a bare `u64`. Because they were the same primitive, nothing
10//! distinguished a height from an unrelated `u64` (or from a `u32` consensus
11//! round), which invited mix-ups. [`BlockHeight`] gives that quantity a name.
12//!
13//! It is `#[repr(transparent)]` and serializes identically to the underlying
14//! `u64` (serde transparent; borsh of a single-field struct), so existing
15//! on-disk and on-chain encodings are unchanged. Conversions are explicit only
16//! ([`BlockHeight::new`] / [`BlockHeight::as_u64`]) — there is deliberately no
17//! `From<u64>`/`Into<u64>`, so an unrelated `u64` cannot silently coerce into a
18//! height at a seam (matching the hardened `ConsensusEpochId` shape).
19
20use std::fmt;
21
22use borsh::{BorshDeserialize, BorshSerialize};
23use serde::{Deserialize, Serialize};
24
25/// A block height (equivalently: consensus slot / report round), in blocks.
26#[derive(
27    BorshSerialize,
28    BorshDeserialize,
29    Clone,
30    Copy,
31    Debug,
32    Default,
33    Eq,
34    Hash,
35    Ord,
36    PartialEq,
37    PartialOrd,
38    Serialize,
39    Deserialize,
40)]
41#[serde(transparent)]
42#[repr(transparent)]
43pub struct BlockHeight(u64);
44
45impl BlockHeight {
46    /// Genesis / zero height.
47    pub const ZERO: Self = Self(0);
48    /// The largest representable height.
49    pub const MAX: Self = Self(u64::MAX);
50
51    /// The size of a borsh-serialized [`BlockHeight`] (identical to `u64`).
52    pub const BORSH_SERIALIZED_SIZE: usize = size_of::<u64>();
53
54    /// Wraps a raw block height.
55    pub const fn new(height: u64) -> Self {
56        Self(height)
57    }
58
59    /// Returns the height as a `u64`.
60    pub const fn as_u64(self) -> u64 {
61        self.0
62    }
63
64    /// Returns the height as little-endian bytes.
65    pub const fn to_le_bytes(self) -> [u8; 8] {
66        self.0.to_le_bytes()
67    }
68
69    /// Constructs from little-endian height bytes.
70    pub const fn from_le_bytes(bytes: [u8; 8]) -> Self {
71        Self(u64::from_le_bytes(bytes))
72    }
73
74    /// Returns the height `n` blocks later, saturating at [`BlockHeight::MAX`].
75    pub const fn saturating_add(self, n: u64) -> Self {
76        Self(self.0.saturating_add(n))
77    }
78
79    /// Returns the height `n` blocks earlier, saturating at [`BlockHeight::ZERO`].
80    pub const fn saturating_sub(self, n: u64) -> Self {
81        Self(self.0.saturating_sub(n))
82    }
83}
84
85impl fmt::Display for BlockHeight {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        // Delegate to the inner `u64` so the caller's format flags (width,
88        // fill, alignment — e.g. `{:012}`) are forwarded, not silently dropped.
89        fmt::Display::fmt(&self.0, f)
90    }
91}
92
93impl std::str::FromStr for BlockHeight {
94    type Err = std::num::ParseIntError;
95    fn from_str(s: &str) -> Result<Self, Self::Err> {
96        s.parse::<u64>().map(Self)
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn new_as_u64_roundtrip() {
106        assert_eq!(BlockHeight::new(42).as_u64(), 42);
107        assert_eq!(BlockHeight::new(7).as_u64(), 7);
108    }
109
110    #[test]
111    fn saturating_arithmetic_clamps() {
112        assert_eq!(BlockHeight::MAX.saturating_add(1), BlockHeight::MAX);
113        assert_eq!(BlockHeight::ZERO.saturating_sub(1), BlockHeight::ZERO);
114        assert_eq!(BlockHeight::new(10).saturating_add(5), BlockHeight::new(15));
115    }
116
117    // `Display` must forward the caller's format flags (width/fill/alignment)
118    // to the inner `u64`, so `{:012}` zero-pads instead of silently emitting a
119    // bare number — archive filenames depend on the padding.
120    #[test]
121    fn display_forwards_format_flags() {
122        assert_eq!(format!("{}", BlockHeight::new(42)), "42");
123        assert_eq!(format!("{:012}", BlockHeight::new(42)), "000000000042");
124    }
125
126    #[test]
127    fn serde_is_transparent() {
128        assert_eq!(serde_json::to_string(&BlockHeight::new(9)).unwrap(), "9");
129        assert_eq!(
130            serde_json::from_str::<BlockHeight>("9").unwrap(),
131            BlockHeight::new(9)
132        );
133    }
134
135    #[test]
136    fn borsh_roundtrips_like_u64() {
137        let h = BlockHeight::new(123);
138        let bytes = borsh::to_vec(&h).unwrap();
139        assert_eq!(bytes, 123u64.to_le_bytes());
140        assert_eq!(borsh::from_slice::<BlockHeight>(&bytes).unwrap(), h);
141    }
142
143    // `RexReport` is read from on-chain state via `bincode` (see
144    // `extract_round_raw`), where `round` was previously a bare `u64`. Pin that
145    // `BlockHeight` is bincode-identical to `u64` so the format stays compatible.
146    #[test]
147    fn bincode_matches_u64() {
148        let raw: u64 = 0xdead_beef;
149        let h = BlockHeight::new(raw);
150        assert_eq!(
151            bincode::serialize(&h).unwrap(),
152            bincode::serialize(&raw).unwrap()
153        );
154        assert_eq!(
155            bincode::deserialize::<BlockHeight>(&bincode::serialize(&raw).unwrap()).unwrap(),
156            h
157        );
158    }
159
160    // Validators sign `compute_record_digest`, which folds the attestation
161    // record's block height via `block_height.to_le_bytes()`. Pin that
162    // `BlockHeight::to_le_bytes` is byte-identical to `u64::to_le_bytes` (and
163    // that `from_le_bytes` round-trips) so migrating the field to `BlockHeight`
164    // leaves the signed digest bytes unchanged.
165    #[test]
166    fn to_le_bytes_matches_u64() {
167        let raw: u64 = 0xdead_beef;
168        let h = BlockHeight::new(raw);
169        assert_eq!(h.to_le_bytes(), raw.to_le_bytes());
170        assert_eq!(BlockHeight::from_le_bytes(h.to_le_bytes()), h);
171    }
172
173    // The indexer's RocksDB `DBMap`s serialize keys and values with `bcs`, and
174    // some of those hold `BlockHeight` (e.g. `IndexSlot`) where a bare `u64` was
175    // stored before. Pin that `BlockHeight` is bcs-identical to `u64` — both
176    // standalone and as a struct field — so existing on-disk data stays readable.
177    #[test]
178    fn bcs_matches_u64() {
179        let raw: u64 = 0xdead_beef;
180        let h = BlockHeight::new(raw);
181        assert_eq!(bcs::to_bytes(&h).unwrap(), bcs::to_bytes(&raw).unwrap());
182        assert_eq!(
183            bcs::from_bytes::<BlockHeight>(&bcs::to_bytes(&raw).unwrap()).unwrap(),
184            h
185        );
186
187        #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
188        struct WrappedHeight {
189            height: BlockHeight,
190        }
191        #[derive(serde::Serialize)]
192        struct WrappedRaw {
193            height: u64,
194        }
195        let wrapped = WrappedHeight { height: h };
196        assert_eq!(
197            bcs::to_bytes(&wrapped).unwrap(),
198            bcs::to_bytes(&WrappedRaw { height: raw }).unwrap()
199        );
200        assert_eq!(
201            bcs::from_bytes::<WrappedHeight>(&bcs::to_bytes(&wrapped).unwrap()).unwrap(),
202            wrapped
203        );
204    }
205}