Skip to content

Commit e0436b8

Browse files
committed
feat: perms module
1 parent 697f776 commit e0436b8

File tree

5 files changed

+295
-2
lines changed

5 files changed

+295
-2
lines changed

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ metrics-exporter-prometheus = "0.16.2"
3333
# Other
3434
thiserror = "2.0.11"
3535
alloy = { version = "0.12.6", optional = true, default-features = false, features = ["std"] }
36+
chrono = { version = "0.4.40", optional = true }
3637

3738
[dev-dependencies]
3839
ajj = "0.3.1"
@@ -44,3 +45,4 @@ tokio = { version = "1.43.0", features = ["macros"] }
4445
[features]
4546
default = ["alloy"]
4647
alloy = ["dep:alloy"]
48+
perms = ["dep:chrono"]

src/lib.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
#![deny(unused_must_use, rust_2018_idioms)]
1313
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
1414

15-
use utils::otlp::OtelGuard;
15+
#[cfg(feature = "perms")]
16+
/// Permissioning and authorization utilities for Signet builders.
17+
pub mod perms;
1618

1719
/// Signet utilities.
1820
pub mod utils {
@@ -64,7 +66,7 @@ pub mod deps {
6466
///
6567
/// [`init_tracing`]: utils::tracing::init_tracing
6668
/// [`init_metrics`]: utils::metrics::init_metrics
67-
pub fn init4() -> Option<OtelGuard> {
69+
pub fn init4() -> Option<utils::otlp::OtelGuard> {
6870
let guard = utils::tracing::init_tracing();
6971
utils::metrics::init_metrics();
7072
guard

src/perms/builders.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
//! #Signet Quincey builder permissioning system.
2+
//!
3+
//! The permissioning system decides which builder can perform a certain action at a given time.
4+
//! The permissioning system uses a simple round-robin design, where each builder is allowed to perform an action at a specific slot.
5+
//! Builders are permissioned based on their sub, which is present in the JWT token they acquire from our OAuth service.
6+
//! They are rotated every 12 seconds, which is Ethereum's slot time.
7+
//! As the logic is timestamp based, the system is deterministic.
8+
//!
9+
//! For updating the currently permissioned builders,
10+
//! Simply update the included `builders.json` file with the new builders.
11+
12+
use crate::utils::from_env::{FromEnvErr, FromEnvVar};
13+
14+
/// The start timestamp for the permissioned builders, in seconds.
15+
const EPOCH_START: u64 = 0;
16+
17+
/// Ethereum's slot time in seconds.
18+
pub const ETHEREUM_SLOT_TIME: u64 = 12;
19+
20+
fn now() -> u64 {
21+
chrono::Utc::now().timestamp().try_into().unwrap()
22+
}
23+
24+
/// Possible errors when permissioning a builder.
25+
#[derive(Debug, thiserror::Error)]
26+
pub enum BuilderPermissionError {
27+
/// Action attempt too early.
28+
#[error("action attempt too early")]
29+
ActionAttemptTooEarly,
30+
31+
/// Action attempt too late.
32+
#[error("action attempt too late")]
33+
ActionAttemptTooLate,
34+
35+
/// Builder not permissioned for this slot.
36+
#[error("builder not permissioned for this slot")]
37+
NotPermissioned,
38+
39+
/// Error loading the environment variable.
40+
#[error(
41+
"failed to parse environment variable. Expected a comma-seperated list of UUIDs. Got: {input}"
42+
)]
43+
ParseError {
44+
/// The environment variable name.
45+
env_var: String,
46+
/// The contents of the environment variable.
47+
input: String,
48+
},
49+
}
50+
51+
/// An individual builder.
52+
#[derive(Clone, Debug)]
53+
pub struct Builder {
54+
/// The sub of the builder.
55+
pub sub: String,
56+
}
57+
58+
impl Builder {
59+
/// Create a new builder.
60+
pub fn new(sub: impl AsRef<str>) -> Self {
61+
Self {
62+
sub: sub.as_ref().to_owned(),
63+
}
64+
}
65+
/// Get the sub of the builder.
66+
pub fn sub(&self) -> &str {
67+
&self.sub
68+
}
69+
}
70+
71+
/// Builders struct to keep track of the builders that are allowed to perform actions.
72+
#[derive(Clone, Debug)]
73+
pub struct Builders {
74+
/// The list of builders.
75+
pub builders: Vec<Builder>,
76+
}
77+
78+
impl Builders {
79+
/// Create a new Builders struct.
80+
pub const fn new(builders: Vec<Builder>) -> Self {
81+
Self { builders }
82+
}
83+
84+
/// Get the builder at a specific index.
85+
///
86+
/// # Panics
87+
///
88+
/// Panics if the index is out of bounds from the builders array.
89+
pub fn builder_at(&self, index: usize) -> &Builder {
90+
&self.builders[index]
91+
}
92+
93+
/// Get the builder permissioned at a specific timestamp.
94+
pub fn builder_at_timestamp(&self, timestamp: u64) -> &Builder {
95+
self.builder_at(self.index(timestamp) as usize)
96+
}
97+
98+
/// Get the index of the builder that is allowed to sign a block for a
99+
/// particular timestamp.
100+
pub fn index(&self, timestamp: u64) -> u64 {
101+
((timestamp - EPOCH_START) / ETHEREUM_SLOT_TIME) % self.builders.len() as u64
102+
}
103+
104+
/// Get the index of the builder that is allowed to sign a block at the
105+
/// current timestamp.
106+
pub fn index_now(&self) -> u64 {
107+
self.index(now())
108+
}
109+
110+
/// Get the builder that is allowed to sign a block at the current timestamp.
111+
pub fn current_builder(&self) -> &Builder {
112+
self.builder_at(self.index_now() as usize)
113+
}
114+
115+
/// Checks if a builder is allowed to perform an action.
116+
/// This is based on the current timestamp and the builder's sub. It's a
117+
/// round-robin design, where each builder is allowed to perform an action
118+
/// at a specific slot, and what builder is allowed changes with each slot.
119+
pub fn is_builder_permissioned(
120+
&self,
121+
config: &crate::perms::SlotAuthzConfig,
122+
sub: &str,
123+
) -> Result<(), BuilderPermissionError> {
124+
// Get the current timestamp.
125+
let curr_timestamp = now();
126+
127+
// Calculate the current slot time, which is a number between 0 and 11.
128+
let current_slot_time = (curr_timestamp - config.chain_offset()) % ETHEREUM_SLOT_TIME;
129+
130+
// Builders can only perform actions between the configured start and cutoff times, to prevent any timing games.
131+
if current_slot_time < config.block_query_start() {
132+
tracing::debug!("Action attempt too early");
133+
return Err(BuilderPermissionError::ActionAttemptTooEarly);
134+
}
135+
if current_slot_time > config.block_query_cutoff() {
136+
tracing::debug!("Action attempt too late");
137+
return Err(BuilderPermissionError::ActionAttemptTooLate);
138+
}
139+
140+
if sub != self.current_builder().sub {
141+
tracing::debug!(
142+
builder = %sub,
143+
permissioned_builder = %self.current_builder().sub,
144+
"Builder not permissioned for this slot"
145+
);
146+
return Err(BuilderPermissionError::NotPermissioned);
147+
}
148+
149+
Ok(())
150+
}
151+
}
152+
153+
impl FromIterator<Builder> for Builders {
154+
fn from_iter<T: IntoIterator<Item = Builder>>(iter: T) -> Self {
155+
Self::new(iter.into_iter().collect())
156+
}
157+
}
158+
159+
impl FromEnvVar for Builders {
160+
type Error = BuilderPermissionError;
161+
162+
fn from_env_var(env_var: &str) -> Result<Self, FromEnvErr<Self::Error>> {
163+
let s = String::from_env_var(env_var)
164+
.map_err(FromEnvErr::infallible_into::<BuilderPermissionError>)?;
165+
166+
Ok(s.split(',').map(Builder::new).collect())
167+
}
168+
}
169+
170+
#[cfg(test)]
171+
mod test {
172+
use super::*;
173+
174+
#[test]
175+
fn load_builders() {
176+
unsafe { std::env::set_var("TEST", "0,1,2,3,4,5") };
177+
178+
let builders = Builders::from_env_var("TEST").unwrap();
179+
assert_eq!(builders.builder_at(0).sub, "0");
180+
assert_eq!(builders.builder_at(1).sub, "1");
181+
assert_eq!(builders.builder_at(2).sub, "2");
182+
assert_eq!(builders.builder_at(3).sub, "3");
183+
assert_eq!(builders.builder_at(4).sub, "4");
184+
assert_eq!(builders.builder_at(5).sub, "5");
185+
}
186+
}

src/perms/config.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
use crate::utils::from_env::{FromEnv, FromEnvErr, FromEnvVar};
2+
use core::num;
3+
4+
// Environment variable names for configuration
5+
const CHAIN_OFFSET: &str = "CHAIN_OFFSET";
6+
const BLOCK_QUERY_CUTOFF: &str = "BLOCK_QUERY_CUTOFF";
7+
const BLOCK_QUERY_START: &str = "BLOCK_QUERY_START";
8+
9+
/// Possible errors when loading the slot authorization configuration.
10+
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
11+
pub enum SlotAuthzConfigError {
12+
/// Error reading environment variable.
13+
#[error("error reading chain offset: {0}")]
14+
ChainOffset(num::ParseIntError),
15+
/// Error reading block query cutoff.
16+
#[error("error reading block query cutoff: {0}")]
17+
BlockQueryCutoff(num::ParseIntError),
18+
/// Error reading block query start.
19+
#[error("error reading block query start: {0}")]
20+
BlockQueryStart(num::ParseIntError),
21+
}
22+
23+
/// Configuration object that describes the slot time settings for a chain.
24+
///
25+
/// This struct is used to configure the slot authorization system
26+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27+
pub struct SlotAuthzConfig {
28+
/// The chain offset in seconds. The offset is the a block's timestamp %
29+
/// its slot duration. This is used to calculate the slot number for a
30+
/// given unix epoch timestamp.
31+
///
32+
/// On loading from env, the number will be clamped between 0 and 11, as
33+
/// the slot duration is 12 seconds.
34+
chain_offset: u8,
35+
/// The block query cutoff time in seconds. This is the slot second after
36+
/// which requests will not be serviced. E.g. a value of 1 means that
37+
/// requests will not be serviced for the last second of any given slot.
38+
///
39+
/// On loading from env, the number will be clamped between 0 and 11, as
40+
/// the slot duration is 12 seconds.
41+
block_query_cutoff: u8,
42+
/// The block query start time in seconds. This is the slot second before
43+
/// which requests will not be serviced. E.g. a value of 1 means that
44+
/// requests will not be serviced for the first second of any given slot.
45+
///
46+
/// On loading from env, the number will be clamped between 0 and 11, as
47+
/// the slot duration is 12 seconds.
48+
block_query_start: u8,
49+
}
50+
51+
impl SlotAuthzConfig {
52+
/// Creates a new `SlotAuthzConfig` with the given parameters, clamping the
53+
/// values between 0 and 11.
54+
pub fn new(chain_offset: u8, block_query_cutoff: u8, block_query_start: u8) -> Self {
55+
Self {
56+
chain_offset: chain_offset.clamp(0, 11),
57+
block_query_cutoff: block_query_cutoff.clamp(0, 11),
58+
block_query_start: block_query_start.clamp(0, 11),
59+
}
60+
}
61+
62+
/// Get the chain offset in seconds.
63+
pub fn chain_offset(&self) -> u64 {
64+
self.chain_offset as u64
65+
}
66+
67+
/// Get the block query cutoff time in seconds.
68+
pub fn block_query_cutoff(&self) -> u64 {
69+
self.block_query_cutoff as u64
70+
}
71+
72+
/// Get the block query start time in seconds.
73+
pub fn block_query_start(&self) -> u64 {
74+
self.block_query_start as u64
75+
}
76+
}
77+
78+
impl FromEnv for SlotAuthzConfig {
79+
type Error = SlotAuthzConfigError;
80+
81+
fn from_env() -> Result<Self, FromEnvErr<Self::Error>> {
82+
let chain_offset = u8::from_env_var(CHAIN_OFFSET)
83+
.map_err(|e| e.map(SlotAuthzConfigError::ChainOffset))?
84+
.clamp(0, 11);
85+
let block_query_cutoff = u8::from_env_var(BLOCK_QUERY_CUTOFF)
86+
.map_err(|e| e.map(SlotAuthzConfigError::BlockQueryCutoff))?
87+
.clamp(0, 11);
88+
let block_query_start = u8::from_env_var(BLOCK_QUERY_START)
89+
.map_err(|e| e.map(SlotAuthzConfigError::BlockQueryStart))?
90+
.clamp(0, 11);
91+
92+
Ok(Self {
93+
chain_offset,
94+
block_query_cutoff,
95+
block_query_start,
96+
})
97+
}
98+
}

src/perms/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
mod config;
2+
pub use config::{SlotAuthzConfig, SlotAuthzConfigError};
3+
4+
mod builders;
5+
pub use builders::{Builder, BuilderPermissionError, Builders, ETHEREUM_SLOT_TIME};

0 commit comments

Comments
 (0)