diff --git a/src/beacon_chain/mod.rs b/src/beacon_chain/mod.rs index 3039900..830eced 100644 --- a/src/beacon_chain/mod.rs +++ b/src/beacon_chain/mod.rs @@ -2,5 +2,6 @@ pub mod constants; pub mod containers; pub mod process_payload_attestation; pub mod process_payload_bid; +pub mod registry; pub mod types; pub mod withdrawals; diff --git a/src/beacon_chain/registry.rs b/src/beacon_chain/registry.rs new file mode 100644 index 0000000..09fd6eb --- /dev/null +++ b/src/beacon_chain/registry.rs @@ -0,0 +1,122 @@ +//! Builder registry and withdrawal flows (consensus-specs inspired). +//! +//! This module keeps the state surface minimal while enforcing: +//! - bounded registry size (`BUILDER_REGISTRY_LIMIT`) +//! - bounded pending withdrawals (`BUILDER_PENDING_WITHDRAWALS_LIMIT`) +//! - withdrawability gating by epoch (`withdrawable_epoch`) +//! +//! It does not implement deposits; tests inject builders directly via the +//! state trait to keep the scope small. + +use crate::beacon_chain::{ + constants::{ + BUILDER_PENDING_WITHDRAWALS_LIMIT, BUILDER_REGISTRY_LIMIT, + MAX_BUILDERS_PER_WITHDRAWALS_SWEEP, + }, + containers::{Builder, BuilderPendingWithdrawal}, + types::{BuilderIndex, Epoch, Gwei}, +}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RegistryError { + #[error("builder registry full (limit {0})")] + RegistryFull(u64), + + #[error("builder {0} already exists")] + BuilderExists(BuilderIndex), + + #[error("unknown builder {0}")] + UnknownBuilder(BuilderIndex), + + #[error("builder {index} not withdrawable until epoch {required}, current {current}")] + NotWithdrawable { + index: BuilderIndex, + required: Epoch, + current: Epoch, + }, + + #[error("builder {index} balance {balance} insufficient for withdrawal {amount}")] + InsufficientBalance { + index: BuilderIndex, + balance: Gwei, + amount: Gwei, + }, + + #[error("pending withdrawals queue full (limit {0})")] + PendingQueueFull(u64), +} + +pub trait RegistryState { + fn builder_count(&self) -> u64; + fn get_builder(&self, index: BuilderIndex) -> Option; + fn insert_builder(&mut self, index: BuilderIndex, builder: Builder); + fn debit_builder_balance(&mut self, index: BuilderIndex, amount: Gwei); + + fn push_pending_withdrawal(&mut self, w: BuilderPendingWithdrawal); + fn pending_withdrawals_len(&self) -> u64; + fn pop_pending_withdrawals(&mut self, max: u64) -> Vec; +} + +/// Register a builder if space permits. +pub fn register_builder( + state: &mut S, + index: BuilderIndex, + builder: Builder, +) -> Result<(), RegistryError> { + if state.builder_count() >= BUILDER_REGISTRY_LIMIT { + return Err(RegistryError::RegistryFull(BUILDER_REGISTRY_LIMIT)); + } + if state.get_builder(index).is_some() { + return Err(RegistryError::BuilderExists(index)); + } + state.insert_builder(index, builder); + Ok(()) +} + +/// Request a withdrawal of builder balance into the execution layer. +pub fn request_builder_withdrawal( + state: &mut S, + index: BuilderIndex, + amount: Gwei, + current_epoch: Epoch, +) -> Result { + let builder = state + .get_builder(index) + .ok_or(RegistryError::UnknownBuilder(index))?; + + if builder.withdrawable_epoch > current_epoch { + return Err(RegistryError::NotWithdrawable { + index, + required: builder.withdrawable_epoch, + current: current_epoch, + }); + } + if builder.balance < amount { + return Err(RegistryError::InsufficientBalance { + index, + balance: builder.balance, + amount, + }); + } + if state.pending_withdrawals_len() >= BUILDER_PENDING_WITHDRAWALS_LIMIT { + return Err(RegistryError::PendingQueueFull( + BUILDER_PENDING_WITHDRAWALS_LIMIT, + )); + } + + let withdrawal = BuilderPendingWithdrawal { + fee_recipient: builder.execution_address, + amount, + builder_index: index, + }; + + state.debit_builder_balance(index, amount); + state.push_pending_withdrawal(withdrawal.clone()); + Ok(withdrawal) +} + +/// Pop up to `MAX_BUILDERS_PER_WITHDRAWALS_SWEEP` pending withdrawals for inclusion. +pub fn sweep_pending_withdrawals(state: &mut S) -> Vec { + state.pop_pending_withdrawals(MAX_BUILDERS_PER_WITHDRAWALS_SWEEP) +} diff --git a/tests/unit/registry_test.rs b/tests/unit/registry_test.rs new file mode 100644 index 0000000..4db6177 --- /dev/null +++ b/tests/unit/registry_test.rs @@ -0,0 +1,117 @@ +use eip_7732::beacon_chain::{ + constants::{ + BUILDER_PENDING_WITHDRAWALS_LIMIT, BUILDER_REGISTRY_LIMIT, + MAX_BUILDERS_PER_WITHDRAWALS_SWEEP, + }, + containers::{Builder, BuilderPendingWithdrawal}, + registry::{register_builder, request_builder_withdrawal, sweep_pending_withdrawals, RegistryError, RegistryState}, + types::*, +}; +use std::collections::{HashMap, VecDeque}; + +struct MockRegistry { + builders: HashMap, + pending: VecDeque, + pending_len_override: Option, +} + +impl MockRegistry { + fn new() -> Self { + Self { builders: HashMap::new(), pending: VecDeque::new(), pending_len_override: None } + } + fn builder(index: BuilderIndex, balance: Gwei, withdrawable_epoch: Epoch) -> Builder { + Builder { + pubkey: [0u8; 48], + version: 0, + execution_address: [0u8; 20], + balance, + deposit_epoch: 0, + withdrawable_epoch, + } + } +} + +impl RegistryState for MockRegistry { + fn builder_count(&self) -> u64 { self.builders.len() as u64 } + fn get_builder(&self, index: BuilderIndex) -> Option { + self.builders.get(&index).cloned() + } + fn insert_builder(&mut self, index: BuilderIndex, builder: Builder) { + self.builders.insert(index, builder); + } + fn debit_builder_balance(&mut self, index: BuilderIndex, amount: Gwei) { + if let Some(b) = self.builders.get_mut(&index) { + b.balance -= amount; + } + } + fn push_pending_withdrawal(&mut self, w: BuilderPendingWithdrawal) { + self.pending.push_back(w); + } + fn pending_withdrawals_len(&self) -> u64 { + self.pending_len_override.unwrap_or(self.pending.len() as u64) + } + fn pop_pending_withdrawals(&mut self, max: u64) -> Vec { + let mut out = Vec::new(); + for _ in 0..max { + if let Some(w) = self.pending.pop_front() { + out.push(w); + } else { + break; + } + } + out + } +} + +#[test] +fn register_builder_succeeds() { + let mut reg = MockRegistry::new(); + let b = MockRegistry::builder(1, 10_000, 5); + assert!(register_builder(&mut reg, 1, b).is_ok()); + assert_eq!(reg.builder_count(), 1); +} + +#[test] +fn register_builder_duplicate_rejected() { + let mut reg = MockRegistry::new(); + let b = MockRegistry::builder(1, 10_000, 5); + register_builder(&mut reg, 1, b.clone()).unwrap(); + let err = register_builder(&mut reg, 1, b).unwrap_err(); + assert!(matches!(err, RegistryError::BuilderExists(1))); +} + +#[test] +fn withdrawal_not_withdrawable_yet() { + let mut reg = MockRegistry::new(); + register_builder(&mut reg, 1, MockRegistry::builder(1, 10_000, 10)).unwrap(); + let err = request_builder_withdrawal(&mut reg, 1, 1_000, 5).unwrap_err(); + assert!(matches!(err, RegistryError::NotWithdrawable { .. })); +} + +#[test] +fn withdrawal_insufficient_balance() { + let mut reg = MockRegistry::new(); + register_builder(&mut reg, 1, MockRegistry::builder(1, 500, 0)).unwrap(); + let err = request_builder_withdrawal(&mut reg, 1, 1_000, 0).unwrap_err(); + assert!(matches!(err, RegistryError::InsufficientBalance { .. })); +} + +#[test] +fn pending_queue_limit_enforced() { + let mut reg = MockRegistry::new(); + register_builder(&mut reg, 1, MockRegistry::builder(1, 10_000, 0)).unwrap(); + reg.pending_len_override = Some(BUILDER_PENDING_WITHDRAWALS_LIMIT); + let err = request_builder_withdrawal(&mut reg, 1, 1, 0).unwrap_err(); + assert!(matches!(err, RegistryError::PendingQueueFull(_))); +} + +#[test] +fn sweep_respects_max_builders_per_sweep() { + let mut reg = MockRegistry::new(); + register_builder(&mut reg, 1, MockRegistry::builder(1, 10_000, 0)).unwrap(); + for _ in 0..(MAX_BUILDERS_PER_WITHDRAWALS_SWEEP + 2) { + request_builder_withdrawal(&mut reg, 1, 1, 0).unwrap(); + } + let swept = sweep_pending_withdrawals(&mut reg); + assert_eq!(swept.len() as u64, MAX_BUILDERS_PER_WITHDRAWALS_SWEEP); +}