diff --git a/Cargo.toml b/Cargo.toml index 39b9dde..6616417 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ name = "init4-bin-base" description = "Internal utilities for binaries produced by the init4 team" keywords = ["init4", "bin", "base"] -version = "0.1.3" +version = "0.1.4" edition = "2021" rust-version = "1.81" authors = ["init4", "James Prestwich"] @@ -30,6 +30,9 @@ url = "2.5.4" metrics = "0.24.1" metrics-exporter-prometheus = "0.16.2" +# Slot Calc +chrono = "0.4.40" + # Other thiserror = "2.0.11" alloy = { version = "0.12.6", optional = true, default-features = false, features = ["std"] } @@ -44,3 +47,4 @@ tokio = { version = "1.43.0", features = ["macros"] } [features] default = ["alloy"] alloy = ["dep:alloy"] +perms = [] diff --git a/src/lib.rs b/src/lib.rs index c6c5ec0..b226b7d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,9 @@ #![deny(unused_must_use, rust_2018_idioms)] #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] -use utils::otlp::OtelGuard; +#[cfg(feature = "perms")] +/// Permissioning and authorization utilities for Signet builders. +pub mod perms; /// Signet utilities. pub mod utils { @@ -30,6 +32,10 @@ pub mod utils { /// Tracing utilities. pub mod tracing; + + /// Slot calculator for determining the current slot and timepoint within a + /// slot. + pub mod calc; } /// Re-exports of common dependencies. @@ -64,7 +70,7 @@ pub mod deps { /// /// [`init_tracing`]: utils::tracing::init_tracing /// [`init_metrics`]: utils::metrics::init_metrics -pub fn init4() -> Option { +pub fn init4() -> Option { let guard = utils::tracing::init_tracing(); utils::metrics::init_metrics(); guard diff --git a/src/perms/builders.rs b/src/perms/builders.rs new file mode 100644 index 0000000..fcf717e --- /dev/null +++ b/src/perms/builders.rs @@ -0,0 +1,221 @@ +//! #Signet Quincey builder permissioning system. +//! +//! The permissioning system decides which builder can perform a certain action +//! at a given time. The permissioning system uses a simple round-robin design, +//! where each builder is allowed to perform an action at a specific slot. +//! Builders are permissioned based on their sub, which is present in the JWT +//! token they acquire from our OAuth service. + +use crate::{ + perms::{SlotAuthzConfig, SlotAuthzConfigError}, + utils::{ + calc::SlotCalculator, + from_env::{FromEnv, FromEnvErr, FromEnvVar}, + }, +}; + +/// The builder list env var. +const BUILDERS: &str = "PERMISSIONED_BUILDERS"; + +fn now() -> u64 { + chrono::Utc::now().timestamp().try_into().unwrap() +} + +/// Possible errors when permissioning a builder. +#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] +pub enum BuilderPermissionError { + /// Action attempt too early. + #[error("action attempt too early")] + ActionAttemptTooEarly, + + /// Action attempt too late. + #[error("action attempt too late")] + ActionAttemptTooLate, + + /// Builder not permissioned for this slot. + #[error("builder not permissioned for this slot")] + NotPermissioned, +} + +/// Possible errors when loading the builder configuration. +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum BuilderConfigError { + /// Error loading the environment variable. + #[error( + "failed to parse environment variable. Expected a comma-seperated list of UUIDs. Got: {input}" + )] + ParseError { + /// The environment variable name. + env_var: String, + /// The contents of the environment variable. + input: String, + }, + + /// Error loading the slot authorization configuration. + #[error(transparent)] + SlotAutzConfig(#[from] SlotAuthzConfigError), +} + +/// An individual builder. +#[derive(Clone, Debug)] +pub struct Builder { + /// The sub of the builder. + pub sub: String, +} + +impl Builder { + /// Create a new builder. + pub fn new(sub: impl AsRef) -> Self { + Self { + sub: sub.as_ref().to_owned(), + } + } + /// Get the sub of the builder. + #[allow(clippy::missing_const_for_fn)] // false positive, non-const deref + pub fn sub(&self) -> &str { + &self.sub + } +} + +/// Builders struct to keep track of the builders that are allowed to perform actions. +#[derive(Clone, Debug)] +pub struct Builders { + /// The list of builders. + /// + /// This is configured in the environment variable `PERMISSIONED_BUILDERS`, + /// as a list of comma-separated UUIDs. + pub builders: Vec, + + /// The slot authorization configuration. See [`SlotAuthzConfig`] for more + /// information and env vars + config: SlotAuthzConfig, +} + +impl Builders { + /// Create a new Builders struct. + pub const fn new(builders: Vec, config: SlotAuthzConfig) -> Self { + Self { builders, config } + } + + /// Get the calculator instance. + pub const fn calc(&self) -> SlotCalculator { + self.config.calc() + } + + /// Get the slot authorization configuration. + pub const fn config(&self) -> &SlotAuthzConfig { + &self.config + } + + /// Get the builder at a specific index. + /// + /// # Panics + /// + /// Panics if the index is out of bounds from the builders array. + pub fn builder_at(&self, index: usize) -> &Builder { + &self.builders[index] + } + + /// Get the builder permissioned at a specific timestamp. + pub fn builder_at_timestamp(&self, timestamp: u64) -> &Builder { + self.builder_at(self.index(timestamp) as usize) + } + + /// Get the index of the builder that is allowed to sign a block for a + /// particular timestamp. + pub fn index(&self, timestamp: u64) -> u64 { + self.config.calc().calculate_slot(timestamp) % self.builders.len() as u64 + } + + /// Get the index of the builder that is allowed to sign a block at the + /// current timestamp. + pub fn index_now(&self) -> u64 { + self.index(now()) + } + + /// Get the builder that is allowed to sign a block at the current timestamp. + pub fn current_builder(&self) -> &Builder { + self.builder_at(self.index_now() as usize) + } + + /// Check the query bounds for the current timestamp. + fn check_query_bounds(&self) -> Result<(), BuilderPermissionError> { + let current_slot_time = self.calc().current_timepoint_within_slot(); + if current_slot_time < self.config.block_query_start() { + return Err(BuilderPermissionError::ActionAttemptTooEarly); + } + if current_slot_time > self.config.block_query_cutoff() { + return Err(BuilderPermissionError::ActionAttemptTooLate); + } + Ok(()) + } + + /// Checks if a builder is allowed to perform an action. + /// This is based on the current timestamp and the builder's sub. It's a + /// round-robin design, where each builder is allowed to perform an action + /// at a specific slot, and what builder is allowed changes with each slot. + pub fn is_builder_permissioned(&self, sub: &str) -> Result<(), BuilderPermissionError> { + self.check_query_bounds()?; + + if sub != self.current_builder().sub { + tracing::debug!( + builder = %sub, + permissioned_builder = %self.current_builder().sub, + "Builder not permissioned for this slot" + ); + return Err(BuilderPermissionError::NotPermissioned); + } + + Ok(()) + } +} + +impl FromEnv for Builders { + type Error = BuilderConfigError; + + fn from_env() -> Result> { + let s = String::from_env_var(BUILDERS) + .map_err(FromEnvErr::infallible_into::)?; + let builders = s.split(',').map(Builder::new).collect(); + + let config = SlotAuthzConfig::from_env().map_err(FromEnvErr::from)?; + + Ok(Self { builders, config }) + } +} + +#[cfg(test)] +mod test { + + use super::*; + use crate::{perms, utils::calc}; + + #[test] + fn load_builders() { + unsafe { + std::env::set_var(BUILDERS, "0,1,2,3,4,5"); + + std::env::set_var(calc::START_TIMESTAMP, "1"); + std::env::set_var(calc::SLOT_OFFSET, "0"); + std::env::set_var(calc::SLOT_DURATION, "12"); + + std::env::set_var(perms::config::BLOCK_QUERY_START, "1"); + std::env::set_var(perms::config::BLOCK_QUERY_CUTOFF, "11"); + }; + + let builders = Builders::from_env().unwrap(); + assert_eq!(builders.builder_at(0).sub, "0"); + assert_eq!(builders.builder_at(1).sub, "1"); + assert_eq!(builders.builder_at(2).sub, "2"); + assert_eq!(builders.builder_at(3).sub, "3"); + assert_eq!(builders.builder_at(4).sub, "4"); + assert_eq!(builders.builder_at(5).sub, "5"); + + assert_eq!(builders.calc().slot_offset(), 0); + assert_eq!(builders.calc().slot_duration(), 12); + assert_eq!(builders.calc().start_timestamp(), 1); + + assert_eq!(builders.config.block_query_start(), 1); + assert_eq!(builders.config.block_query_cutoff(), 11); + } +} diff --git a/src/perms/config.rs b/src/perms/config.rs new file mode 100644 index 0000000..38f82f7 --- /dev/null +++ b/src/perms/config.rs @@ -0,0 +1,94 @@ +use crate::utils::{ + calc::{SlotCalcEnvError, SlotCalculator}, + from_env::{FromEnv, FromEnvErr, FromEnvVar}, +}; +use core::num; + +// Environment variable names for configuration +pub(crate) const BLOCK_QUERY_CUTOFF: &str = "BLOCK_QUERY_CUTOFF"; +pub(crate) const BLOCK_QUERY_START: &str = "BLOCK_QUERY_START"; + +/// Possible errors when loading the slot authorization configuration. +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +pub enum SlotAuthzConfigError { + /// Error reading environment variable. + #[error("error reading chain offset: {0}")] + Calculator(#[from] SlotCalcEnvError), + /// Error reading block query cutoff. + #[error("error reading block query cutoff: {0}")] + BlockQueryCutoff(num::ParseIntError), + /// Error reading block query start. + #[error("error reading block query start: {0}")] + BlockQueryStart(num::ParseIntError), +} + +/// Configuration object that describes the slot time settings for a chain. +/// +/// This struct is used to configure the slot authorization system +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SlotAuthzConfig { + /// A [`SlotCalculator`] instance that can be used to calculate the slot + /// number for a given timestamp. + calc: SlotCalculator, + /// The block query cutoff time in seconds. This is the slot second after + /// which requests will not be serviced. E.g. a value of 1 means that + /// requests will not be serviced for the last second of any given slot. + /// + /// On loading from env, the number will be clamped between 0 and 11, as + /// the slot duration is 12 seconds. + block_query_cutoff: u8, + /// The block query start time in seconds. This is the slot second before + /// which requests will not be serviced. E.g. a value of 1 means that + /// requests will not be serviced for the first second of any given slot. + /// + /// On loading from env, the number will be clamped between 0 and 11, as + /// the slot duration is 12 seconds. + block_query_start: u8, +} + +impl SlotAuthzConfig { + /// Creates a new `SlotAuthzConfig` with the given parameters, clamping the + /// values between 0 and `calc.slot_duration()`. + pub fn new(calc: SlotCalculator, block_query_cutoff: u8, block_query_start: u8) -> Self { + Self { + calc, + block_query_cutoff: block_query_cutoff.clamp(0, calc.slot_duration() as u8), + block_query_start: block_query_start.clamp(0, calc.slot_duration() as u8), + } + } + + /// Get the slot calculator instance. + pub const fn calc(&self) -> SlotCalculator { + self.calc + } + + /// Get the block query cutoff time in seconds. + pub const fn block_query_cutoff(&self) -> u64 { + self.block_query_cutoff as u64 + } + + /// Get the block query start time in seconds. + pub const fn block_query_start(&self) -> u64 { + self.block_query_start as u64 + } +} + +impl FromEnv for SlotAuthzConfig { + type Error = SlotAuthzConfigError; + + fn from_env() -> Result> { + let calc = SlotCalculator::from_env().map_err(FromEnvErr::from)?; + let block_query_cutoff = u8::from_env_var(BLOCK_QUERY_CUTOFF) + .map_err(|e| e.map(SlotAuthzConfigError::BlockQueryCutoff))? + .clamp(0, 11); + let block_query_start = u8::from_env_var(BLOCK_QUERY_START) + .map_err(|e| e.map(SlotAuthzConfigError::BlockQueryStart))? + .clamp(0, 11); + + Ok(Self { + calc, + block_query_cutoff, + block_query_start, + }) + } +} diff --git a/src/perms/mod.rs b/src/perms/mod.rs new file mode 100644 index 0000000..413778d --- /dev/null +++ b/src/perms/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod builders; +pub use builders::{Builder, BuilderPermissionError, Builders}; + +pub(crate) mod config; +pub use config::{SlotAuthzConfig, SlotAuthzConfigError}; diff --git a/src/utils/calc.rs b/src/utils/calc.rs new file mode 100644 index 0000000..51175ff --- /dev/null +++ b/src/utils/calc.rs @@ -0,0 +1,237 @@ +use crate::utils::from_env::{FromEnv, FromEnvErr, FromEnvVar}; +use core::num; + +// Env vars +pub(crate) const START_TIMESTAMP: &str = "START_TIMESTAMP"; +pub(crate) const SLOT_OFFSET: &str = "SLOT_OFFSET"; +pub(crate) const SLOT_DURATION: &str = "SLOT_DURATION"; + +/// Possible errors when loading the slot authorization configuration. +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +pub enum SlotCalcEnvError { + /// Error reading environment variable. + #[error("error reading the start timestamp: {0}")] + StartTimestamp(num::ParseIntError), + /// Error reading block query cutoff. + #[error("error reading slot offset: {0}")] + SlotOffset(num::ParseIntError), + /// Error reading block query start. + #[error("error reading slot duration: {0}")] + SlotDuration(num::ParseIntError), +} + +/// A slot calculator, which can calculate the slot number for a given +/// timestamp. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct SlotCalculator { + /// The start timestamp. + start_timestamp: u64, + + /// This is the number of the slot containing the block which contains the + /// `start_timestamp`. + /// + /// This is needed for chains that contain a merge (like Ethereum Mainnet), + /// or for chains with missed slots at the start of the chain (like + /// Holesky). + slot_offset: u64, + + /// The slot duration (in seconds). + slot_duration: u64, +} + +impl SlotCalculator { + /// Creates a new slot calculator. + pub const fn new(start_timestamp: u64, slot_offset: u64, slot_duration: u64) -> Self { + Self { + start_timestamp, + slot_offset, + slot_duration, + } + } + + /// Creates a new slot calculator for Holesky. + pub const fn holesky() -> Self { + // begin slot calculation for Holesky from block number 1, slot number 2, timestamp 1695902424 + // because of a strange 324 second gap between block 0 and 1 which + // should have been 27 slots, but which is recorded as 2 slots in chain data + Self { + start_timestamp: 1695902424, + slot_offset: 2, + slot_duration: 12, + } + } + + /// Creates a new slot calculator for Ethereum mainnet. + pub const fn mainnet() -> Self { + Self { + start_timestamp: 1663224179, + slot_offset: 4700013, + slot_duration: 12, + } + } + + /// Calculates the slot for a given timestamp. + /// This only works for timestamps that are GEQ to the chain's start_timestamp. + pub const fn calculate_slot(&self, timestamp: u64) -> u64 { + let elapsed = timestamp - self.start_timestamp; + let slots = elapsed.div_ceil(self.slot_duration); + slots + self.slot_offset + } + + /// Calculates how many seconds into the block window for a given timestamp. + pub const fn calculate_timepoint_within_slot(&self, timestamp: u64) -> u64 { + (timestamp - self.slot_utc_offset()) % self.slot_duration + } + + /// Calculates the start and end timestamps for a given slot + pub const fn calculate_slot_window(&self, slot_number: u64) -> (u64, u64) { + let end_of_slot = + ((slot_number - self.slot_offset) * self.slot_duration) + self.start_timestamp; + let start_of_slot = end_of_slot - self.slot_duration; + (start_of_slot, end_of_slot) + } + + /// The current slot number. + pub fn current_slot(&self) -> u64 { + self.calculate_slot(chrono::Utc::now().timestamp() as u64) + } + + /// The current number of seconds into the block window. + pub fn current_timepoint_within_slot(&self) -> u64 { + self.calculate_timepoint_within_slot(chrono::Utc::now().timestamp() as u64) + } + + /// The timestamp of the first PoS block in the chain. + pub const fn start_timestamp(&self) -> u64 { + self.start_timestamp + } + + /// The slot number of the first PoS block in the chain. + pub const fn slot_offset(&self) -> u64 { + self.slot_offset + } + + /// The slot duration, usually 12 seconds. + pub const fn slot_duration(&self) -> u64 { + self.slot_duration + } + + /// The offset in seconds between UTC time and slot mining times + const fn slot_utc_offset(&self) -> u64 { + self.start_timestamp % self.slot_duration + } +} + +impl FromEnv for SlotCalculator { + type Error = SlotCalcEnvError; + + fn from_env() -> Result> { + let start_timestamp = u64::from_env_var(START_TIMESTAMP) + .map_err(|e| e.map(SlotCalcEnvError::StartTimestamp))?; + let slot_offset = + u64::from_env_var(SLOT_OFFSET).map_err(|e| e.map(SlotCalcEnvError::SlotOffset))?; + + let slot_duration = + u64::from_env_var(SLOT_DURATION).map_err(|e| e.map(SlotCalcEnvError::SlotDuration))?; + + Ok(Self::new(start_timestamp, slot_offset, slot_duration)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_slot_calculations() { + let calculator = SlotCalculator::new(0, 0, 12); + assert_eq!(calculator.calculate_slot(0), 0); + + assert_eq!(calculator.calculate_slot(1), 1); + assert_eq!(calculator.calculate_slot(11), 1); + assert_eq!(calculator.calculate_slot(12), 1); + + assert_eq!(calculator.calculate_slot(13), 2); + assert_eq!(calculator.calculate_slot(23), 2); + assert_eq!(calculator.calculate_slot(24), 2); + + assert_eq!(calculator.calculate_slot(25), 3); + assert_eq!(calculator.calculate_slot(35), 3); + assert_eq!(calculator.calculate_slot(36), 3); + } + + #[test] + fn test_holesky_slot_calculations() { + let calculator = SlotCalculator::holesky(); + // block 1 == slot 2 == timestamp 1695902424 + // timestamp 1695902424 == slot 2 + assert_eq!(calculator.calculate_slot(1695902424), 2); + // the next second, timestamp 1695902425 == slot 3 + assert_eq!(calculator.calculate_slot(1695902425), 3); + + // block 3557085 == slot 3919127 == timestamp 1742931924 + // timestamp 1742931924 == slot 3919127 + assert_eq!(calculator.calculate_slot(1742931924), 3919127); + // the next second, timestamp 1742931925 == slot 3919128 + assert_eq!(calculator.calculate_slot(1742931925), 3919128); + } + + #[test] + fn test_holesky_slot_timepoint_calculations() { + let calculator = SlotCalculator::holesky(); + // calculate timepoint in slot + assert_eq!(calculator.calculate_timepoint_within_slot(1695902424), 0); + assert_eq!(calculator.calculate_timepoint_within_slot(1695902425), 1); + assert_eq!(calculator.calculate_timepoint_within_slot(1695902435), 11); + assert_eq!(calculator.calculate_timepoint_within_slot(1695902436), 0); + } + + #[test] + fn test_holesky_slot_window() { + let calculator = SlotCalculator::holesky(); + // calculate slot window + assert_eq!( + calculator.calculate_slot_window(2), + (1695902412, 1695902424) + ); + assert_eq!( + calculator.calculate_slot_window(3), + (1695902424, 1695902436) + ); + } + + #[test] + fn test_mainnet_slot_calculations() { + let calculator = SlotCalculator::mainnet(); + assert_eq!(calculator.calculate_slot(1663224179), 4700013); + assert_eq!(calculator.calculate_slot(1663224180), 4700014); + + assert_eq!(calculator.calculate_slot(1738863035), 11003251); + assert_eq!(calculator.calculate_slot(1738866239), 11003518); + assert_eq!(calculator.calculate_slot(1738866227), 11003517); + } + + #[test] + fn test_mainnet_slot_timepoint_calculations() { + let calculator = SlotCalculator::mainnet(); + // calculate timepoint in slot + assert_eq!(calculator.calculate_timepoint_within_slot(1663224179), 0); + assert_eq!(calculator.calculate_timepoint_within_slot(1663224180), 1); + assert_eq!(calculator.calculate_timepoint_within_slot(1663224190), 11); + assert_eq!(calculator.calculate_timepoint_within_slot(1663224191), 0); + } + + #[test] + fn test_ethereum_slot_window() { + let calculator = SlotCalculator::mainnet(); + // calculate slot window + assert_eq!( + calculator.calculate_slot_window(4700013), + (1663224167, 1663224179) + ); + assert_eq!( + calculator.calculate_slot_window(4700014), + (1663224179, 1663224191) + ); + } +}