rialo-types 0.18.1

Rialo Types
Documentation
// Copyright (c) Subzero Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

//! Strongly-typed chain position.
//!
//! A monotonically increasing block height. In this system the consensus
//! *slot*, the report *round*, and the ledger *block height* are the same
//! monotonic quantity (`round == clock.slot == block_height`), historically all
//! carried as a bare `u64`. Because they were the same primitive, nothing
//! distinguished a height from an unrelated `u64` (or from a `u32` consensus
//! round), which invited mix-ups. [`BlockHeight`] gives that quantity a name.
//!
//! It is `#[repr(transparent)]` and serializes identically to the underlying
//! `u64` (serde transparent; borsh of a single-field struct), so existing
//! on-disk and on-chain encodings are unchanged. Conversions are explicit only
//! ([`BlockHeight::new`] / [`BlockHeight::as_u64`]) — there is deliberately no
//! `From<u64>`/`Into<u64>`, so an unrelated `u64` cannot silently coerce into a
//! height at a seam (matching the hardened `ConsensusEpochId` shape).

use std::fmt;

use borsh::{BorshDeserialize, BorshSerialize};
use serde::{Deserialize, Serialize};

/// A block height (equivalently: consensus slot / report round), in blocks.
#[derive(
    BorshSerialize,
    BorshDeserialize,
    Clone,
    Copy,
    Debug,
    Default,
    Eq,
    Hash,
    Ord,
    PartialEq,
    PartialOrd,
    Serialize,
    Deserialize,
)]
#[serde(transparent)]
#[repr(transparent)]
pub struct BlockHeight(u64);

impl BlockHeight {
    /// Genesis / zero height.
    pub const ZERO: Self = Self(0);
    /// The largest representable height.
    pub const MAX: Self = Self(u64::MAX);

    /// The size of a borsh-serialized [`BlockHeight`] (identical to `u64`).
    pub const BORSH_SERIALIZED_SIZE: usize = size_of::<u64>();

    /// Wraps a raw block height.
    pub const fn new(height: u64) -> Self {
        Self(height)
    }

    /// Returns the height as a `u64`.
    pub const fn as_u64(self) -> u64 {
        self.0
    }

    /// Returns the height as little-endian bytes.
    pub const fn to_le_bytes(self) -> [u8; 8] {
        self.0.to_le_bytes()
    }

    /// Constructs from little-endian height bytes.
    pub const fn from_le_bytes(bytes: [u8; 8]) -> Self {
        Self(u64::from_le_bytes(bytes))
    }

    /// Returns the height `n` blocks later, saturating at [`BlockHeight::MAX`].
    pub const fn saturating_add(self, n: u64) -> Self {
        Self(self.0.saturating_add(n))
    }

    /// Returns the height `n` blocks earlier, saturating at [`BlockHeight::ZERO`].
    pub const fn saturating_sub(self, n: u64) -> Self {
        Self(self.0.saturating_sub(n))
    }
}

impl fmt::Display for BlockHeight {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // Delegate to the inner `u64` so the caller's format flags (width,
        // fill, alignment — e.g. `{:012}`) are forwarded, not silently dropped.
        fmt::Display::fmt(&self.0, f)
    }
}

impl std::str::FromStr for BlockHeight {
    type Err = std::num::ParseIntError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        s.parse::<u64>().map(Self)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn new_as_u64_roundtrip() {
        assert_eq!(BlockHeight::new(42).as_u64(), 42);
        assert_eq!(BlockHeight::new(7).as_u64(), 7);
    }

    #[test]
    fn saturating_arithmetic_clamps() {
        assert_eq!(BlockHeight::MAX.saturating_add(1), BlockHeight::MAX);
        assert_eq!(BlockHeight::ZERO.saturating_sub(1), BlockHeight::ZERO);
        assert_eq!(BlockHeight::new(10).saturating_add(5), BlockHeight::new(15));
    }

    // `Display` must forward the caller's format flags (width/fill/alignment)
    // to the inner `u64`, so `{:012}` zero-pads instead of silently emitting a
    // bare number — archive filenames depend on the padding.
    #[test]
    fn display_forwards_format_flags() {
        assert_eq!(format!("{}", BlockHeight::new(42)), "42");
        assert_eq!(format!("{:012}", BlockHeight::new(42)), "000000000042");
    }

    #[test]
    fn serde_is_transparent() {
        assert_eq!(serde_json::to_string(&BlockHeight::new(9)).unwrap(), "9");
        assert_eq!(
            serde_json::from_str::<BlockHeight>("9").unwrap(),
            BlockHeight::new(9)
        );
    }

    #[test]
    fn borsh_roundtrips_like_u64() {
        let h = BlockHeight::new(123);
        let bytes = borsh::to_vec(&h).unwrap();
        assert_eq!(bytes, 123u64.to_le_bytes());
        assert_eq!(borsh::from_slice::<BlockHeight>(&bytes).unwrap(), h);
    }

    // `RexReport` is read from on-chain state via `bincode` (see
    // `extract_round_raw`), where `round` was previously a bare `u64`. Pin that
    // `BlockHeight` is bincode-identical to `u64` so the format stays compatible.
    #[test]
    fn bincode_matches_u64() {
        let raw: u64 = 0xdead_beef;
        let h = BlockHeight::new(raw);
        assert_eq!(
            bincode::serialize(&h).unwrap(),
            bincode::serialize(&raw).unwrap()
        );
        assert_eq!(
            bincode::deserialize::<BlockHeight>(&bincode::serialize(&raw).unwrap()).unwrap(),
            h
        );
    }

    // Validators sign `compute_record_digest`, which folds the attestation
    // record's block height via `block_height.to_le_bytes()`. Pin that
    // `BlockHeight::to_le_bytes` is byte-identical to `u64::to_le_bytes` (and
    // that `from_le_bytes` round-trips) so migrating the field to `BlockHeight`
    // leaves the signed digest bytes unchanged.
    #[test]
    fn to_le_bytes_matches_u64() {
        let raw: u64 = 0xdead_beef;
        let h = BlockHeight::new(raw);
        assert_eq!(h.to_le_bytes(), raw.to_le_bytes());
        assert_eq!(BlockHeight::from_le_bytes(h.to_le_bytes()), h);
    }

    // The indexer's RocksDB `DBMap`s serialize keys and values with `bcs`, and
    // some of those hold `BlockHeight` (e.g. `IndexSlot`) where a bare `u64` was
    // stored before. Pin that `BlockHeight` is bcs-identical to `u64` — both
    // standalone and as a struct field — so existing on-disk data stays readable.
    #[test]
    fn bcs_matches_u64() {
        let raw: u64 = 0xdead_beef;
        let h = BlockHeight::new(raw);
        assert_eq!(bcs::to_bytes(&h).unwrap(), bcs::to_bytes(&raw).unwrap());
        assert_eq!(
            bcs::from_bytes::<BlockHeight>(&bcs::to_bytes(&raw).unwrap()).unwrap(),
            h
        );

        #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
        struct WrappedHeight {
            height: BlockHeight,
        }
        #[derive(serde::Serialize)]
        struct WrappedRaw {
            height: u64,
        }
        let wrapped = WrappedHeight { height: h };
        assert_eq!(
            bcs::to_bytes(&wrapped).unwrap(),
            bcs::to_bytes(&WrappedRaw { height: raw }).unwrap()
        );
        assert_eq!(
            bcs::from_bytes::<WrappedHeight>(&bcs::to_bytes(&wrapped).unwrap()).unwrap(),
            wrapped
        );
    }
}