Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/beacon_chain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
122 changes: 122 additions & 0 deletions src/beacon_chain/registry.rs
Original file line number Diff line number Diff line change
@@ -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<Builder>;
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<BuilderPendingWithdrawal>;
}

/// Register a builder if space permits.
pub fn register_builder<S: RegistryState>(
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<S: RegistryState>(
state: &mut S,
index: BuilderIndex,
amount: Gwei,
current_epoch: Epoch,
) -> Result<BuilderPendingWithdrawal, RegistryError> {
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<S: RegistryState>(state: &mut S) -> Vec<BuilderPendingWithdrawal> {
state.pop_pending_withdrawals(MAX_BUILDERS_PER_WITHDRAWALS_SWEEP)
}
117 changes: 117 additions & 0 deletions tests/unit/registry_test.rs
Original file line number Diff line number Diff line change
@@ -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<BuilderIndex, Builder>,
pending: VecDeque<BuilderPendingWithdrawal>,
pending_len_override: Option<u64>,
}

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<Builder> {
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<BuilderPendingWithdrawal> {
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);
}
Loading