From ee2368e9a764781bd8fb962ff016427540f356ea Mon Sep 17 00:00:00 2001 From: Quang Le Date: Wed, 18 Feb 2026 17:09:53 +0700 Subject: [PATCH 1/4] feat(eth2util): implement registration --- crates/eth2api/src/lib.rs | 3 + crates/eth2api/src/spec/bellatrix.rs | 6 + crates/eth2api/src/spec/mod.rs | 3 + crates/eth2api/src/v1.rs | 42 +++++ crates/eth2util/src/lib.rs | 3 + crates/eth2util/src/registration.rs | 243 +++++++++++++++++++++++++++ 6 files changed, 300 insertions(+) create mode 100644 crates/eth2api/src/spec/bellatrix.rs create mode 100644 crates/eth2api/src/v1.rs create mode 100644 crates/eth2util/src/registration.rs diff --git a/crates/eth2api/src/lib.rs b/crates/eth2api/src/lib.rs index ce853dc4..6c11cb7e 100644 --- a/crates/eth2api/src/lib.rs +++ b/crates/eth2api/src/lib.rs @@ -25,6 +25,9 @@ pub use extensions::*; /// Ethereum 2.0 consensus layer specification types. pub mod spec; +/// API v1 types from the Ethereum beacon chain and builder API specifications. +pub mod v1; + #[cfg(test)] #[cfg(feature = "integration")] mod integration; diff --git a/crates/eth2api/src/spec/bellatrix.rs b/crates/eth2api/src/spec/bellatrix.rs new file mode 100644 index 00000000..36fcff70 --- /dev/null +++ b/crates/eth2api/src/spec/bellatrix.rs @@ -0,0 +1,6 @@ +//! Bellatrix consensus types from the Ethereum beacon chain specification. +//! +//! See: + +/// An execution layer address (20 bytes). +pub type ExecutionAddress = [u8; 20]; diff --git a/crates/eth2api/src/spec/mod.rs b/crates/eth2api/src/spec/mod.rs index 8f10036c..a0f35583 100644 --- a/crates/eth2api/src/spec/mod.rs +++ b/crates/eth2api/src/spec/mod.rs @@ -2,3 +2,6 @@ /// Phase 0 consensus types from the Ethereum beacon chain specification. pub mod phase0; + +/// Bellatrix consensus types from the Ethereum beacon chain specification. +pub mod bellatrix; diff --git a/crates/eth2api/src/v1.rs b/crates/eth2api/src/v1.rs new file mode 100644 index 00000000..2496b82d --- /dev/null +++ b/crates/eth2api/src/v1.rs @@ -0,0 +1,42 @@ +//! API v1 types from the Ethereum beacon chain and builder API specifications. + +use tree_hash_derive::TreeHash; + +use crate::spec::{bellatrix::ExecutionAddress, phase0::BLSPubKey}; + +/// Validator registration message for the builder API. +/// +/// See: +#[derive(Debug, Clone, PartialEq, Eq, TreeHash)] +pub struct ValidatorRegistration { + /// Fee recipient address (20 bytes). + pub fee_recipient: ExecutionAddress, + /// Gas limit. + pub gas_limit: u64, + /// Registration timestamp in unix seconds. + pub timestamp: u64, + /// Validator BLS public key (48 bytes). + pub pubkey: BLSPubKey, +} + +#[cfg(test)] +mod tests { + use super::*; + use tree_hash::TreeHash; + + #[test] + fn validator_registration_tree_hash() { + let reg = ValidatorRegistration { + fee_recipient: [0xAA; 20], + gas_limit: 30_000_000, + timestamp: 1_000_000, + pubkey: [0xBB; 48], + }; + + let root = reg.tree_hash_root(); + let expected = + hex::decode("51334aceeda4bd921bad529aa54c00536d02950213c44da638ef541efe024d5e") + .unwrap(); + assert_eq!(root.0, expected.as_slice()); + } +} diff --git a/crates/eth2util/src/lib.rs b/crates/eth2util/src/lib.rs index 20f16580..3d94533d 100644 --- a/crates/eth2util/src/lib.rs +++ b/crates/eth2util/src/lib.rs @@ -22,5 +22,8 @@ pub mod deposit; /// Ethereum address helpers. pub mod helpers; +/// Validator registration for builder API. +pub mod registration; + /// Utilities. pub(crate) mod utils; diff --git a/crates/eth2util/src/registration.rs b/crates/eth2util/src/registration.rs new file mode 100644 index 00000000..44b928ab --- /dev/null +++ b/crates/eth2util/src/registration.rs @@ -0,0 +1,243 @@ +use pluto_eth2api::{ + spec::{ + bellatrix::ExecutionAddress, + phase0::{BLSPubKey, Domain, DomainType, ForkData, Root, SigningData, Version}, + }, + v1::ValidatorRegistration, +}; +use tree_hash::TreeHash; + +/// Default gas limit used in validator registration pre-generation. +pub const DEFAULT_GAS_LIMIT: u64 = 30_000_000; + +/// DOMAIN_APPLICATION_BUILDER. +/// See . +const REGISTRATION_DOMAIN_TYPE: DomainType = [0x00, 0x00, 0x00, 0x01]; + +/// Registration error. +#[derive(Debug, thiserror::Error)] +pub enum RegistrationError { + /// Invalid fee recipient address. + #[error("invalid fee recipient address: {0}")] + InvalidAddress(#[from] crate::helpers::HelperError), +} + +type Result = std::result::Result; + +/// Creates a new validator registration message. +pub fn new_message( + pubkey: BLSPubKey, + fee_recipient: &str, + gas_limit: u64, + timestamp: u64, +) -> Result { + let fee_recipient = execution_address_from_str(fee_recipient)?; + + Ok(ValidatorRegistration { + fee_recipient, + gas_limit, + timestamp, + pubkey, + }) +} + +/// Parses and validates a `0x`-prefixed hex Ethereum address into `[u8; 20]`. +fn execution_address_from_str(addr: &str) -> Result { + let address = crate::helpers::verify_address(addr)?; + let mut result = ExecutionAddress::default(); + result.copy_from_slice(address.as_slice()); + Ok(result) +} + +/// Returns the validator registration signature domain. +// DOMAIN_APPLICATION_BUILDER uses GENESIS_FORK_VERSION to compute domain. +// Refer: +// - https://github.com/ethereum/builder-specs/blob/100d4faf32e5dc672c963741769390ff09ab194a/specs/bellatrix/builder.md#signing +// - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#compute_domain +fn get_registration_domain(genesis_fork_version: Version) -> Domain { + let fork_data = ForkData { + current_version: genesis_fork_version, + genesis_validators_root: Root::default(), /* GenesisValidatorsRoot is zero for validator + * registration. */ + }; + + let fork_data_root = fork_data.tree_hash_root(); + + let mut domain = Domain::default(); + domain[0..4].copy_from_slice(®ISTRATION_DOMAIN_TYPE); + domain[4..].copy_from_slice(&fork_data_root.0[..28]); + + domain +} + +/// Returns the validator registration message signing root. +pub fn get_message_signing_root( + msg: &ValidatorRegistration, + genesis_fork_version: Version, +) -> Root { + let msg_root = msg.tree_hash_root(); + let domain = get_registration_domain(genesis_fork_version); + + let signing_data = SigningData { + object_root: msg_root.0, + domain, + }; + + signing_data.tree_hash_root().0 +} + +#[cfg(test)] +mod tests { + use super::*; + use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls}; + + #[test] + fn test_new_message() { + let pubkey = [0xABu8; 48]; + let fee_recipient = "0x321dcb529f3945bc94fecea9d3bc5caf35253b94"; + let gas_limit = 30_000_000u64; + // Jan 1, 2000 00:00:00 UTC in unix seconds + let timestamp = 946_684_800u64; + + let result = new_message(pubkey, fee_recipient, gas_limit, timestamp).unwrap(); + + assert_eq!(result.pubkey, pubkey); + assert_eq!(result.gas_limit, gas_limit); + assert_eq!(result.timestamp, timestamp); + assert_eq!( + result.fee_recipient, + [ + 50, 29, 203, 82, 159, 57, 69, 188, 148, 254, 206, 169, 211, 188, 92, 175, 53, 37, + 59, 148, + ] + ); + } + + #[test] + fn test_new_message_bad_address() { + let pubkey = [0xABu8; 48]; + // Truncated address (39 hex chars instead of 40) + let fee_recipient = "0x321dcb529f3945bc94fecea9d3bc5caf35253b9"; + let gas_limit = 30_000_000u64; + let timestamp = 946_684_800u64; + + let result = new_message(pubkey, fee_recipient, gas_limit, timestamp); + assert!(result.is_err()); + assert!( + result + .as_ref() + .err() + .map(std::string::ToString::to_string) + .is_some_and(|err| err.contains("invalid fee recipient address")) + ); + } + + #[test] + fn test_get_message_signing_root() { + let pubkey = [0xABu8; 48]; + let fee_recipient: ExecutionAddress = [ + 50, 29, 203, 82, 159, 57, 69, 188, 148, 254, 206, 169, 211, 188, 92, 175, 53, 37, 59, + 148, + ]; + + let msg = ValidatorRegistration { + fee_recipient, + gas_limit: 30_000_000, + timestamp: 946_684_800, + pubkey, + }; + + let fork_version_bytes = crate::network::network_to_fork_version_bytes("goerli").unwrap(); + let fork_version: Version = fork_version_bytes.as_slice().try_into().unwrap(); + + let result = get_message_signing_root(&msg, fork_version); + + let expected_root = + hex::decode("a71f91bf1e595fc8d1bb7f33ad7f4a0c228512ec3dbf780302304dc61621b78b") + .unwrap(); + assert_eq!(result, expected_root.as_slice()); + } + + #[test] + fn test_verify_signed_registration() { + // Test data obtained from teku. + let sk_bytes = + hex::decode("345768c0245f1dc702df9e50e811002f61ebb2680b3d5931527ef59f96cbaf9b") + .unwrap(); + let secret: pluto_crypto::types::PrivateKey = sk_bytes.as_slice().try_into().unwrap(); + + let tbls = BlstImpl; + let pubkey = tbls.secret_to_public_key(&secret).unwrap(); + + let registration_json = r#" + { + "message": { + "fee_recipient": "0x000000000000000000000000000000000000dEaD", + "gas_limit": "30000000", + "timestamp": "1646092800", + "pubkey": "0x86966350b672bd502bfbdb37a6ea8a7392e8fb7f5ebb5c5e2055f4ee168ebfab0fef63084f28c9f62c3ba71f825e527e" + }, + "signature": "0xad393c5b42b382cf93cd14f302b0175b4f9ccb000c201d42c3a6389971b8d910a81333d55ad2944b836a9bb35ba968ab06635dcd706380516ad0c653f48b1c6d52b8771c78d708e943b3ea8da59392fbf909decde262adc944fe3e57120d9bb4" + }"#; + let registration: serde_json::Value = serde_json::from_str(registration_json).unwrap(); + let message = registration["message"].as_object().unwrap(); + + let fee_recipient: ExecutionAddress = hex::decode( + message["fee_recipient"] + .as_str() + .unwrap() + .trim_start_matches("0x"), + ) + .unwrap() + .as_slice() + .try_into() + .unwrap(); + let reg_pubkey: BLSPubKey = + hex::decode(message["pubkey"].as_str().unwrap().trim_start_matches("0x")) + .unwrap() + .as_slice() + .try_into() + .unwrap(); + let gas_limit = message["gas_limit"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + let timestamp = message["timestamp"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + + let msg = ValidatorRegistration { + fee_recipient, + gas_limit, + timestamp, + pubkey: reg_pubkey, + }; + + let fork_version_bytes = crate::network::network_to_fork_version_bytes("holesky").unwrap(); + let fork_version: Version = fork_version_bytes.as_slice().try_into().unwrap(); + + let signing_root = get_message_signing_root(&msg, fork_version); + + let expected_root = + hex::decode("fc657efa54a1e050289ddc5a72fbb76c778ac383a3c73309082e01f132ba23a8") + .unwrap(); + assert_eq!(signing_root, expected_root.as_slice()); + + let signature: pluto_crypto::types::Signature = hex::decode( + registration["signature"] + .as_str() + .unwrap() + .trim_start_matches("0x"), + ) + .unwrap() + .as_slice() + .try_into() + .unwrap(); + + tbls.verify(&pubkey, &signing_root, &signature) + .expect("BLS signature verification failed"); + } +} From 095b7e0e09891ee0ccd4528af42e2c3d65412bf7 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Fri, 20 Feb 2026 17:57:09 +0900 Subject: [PATCH 2/4] fix: address comments --- crates/eth2util/src/registration.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/eth2util/src/registration.rs b/crates/eth2util/src/registration.rs index 44b928ab..1a733f5b 100644 --- a/crates/eth2util/src/registration.rs +++ b/crates/eth2util/src/registration.rs @@ -10,7 +10,7 @@ use tree_hash::TreeHash; /// Default gas limit used in validator registration pre-generation. pub const DEFAULT_GAS_LIMIT: u64 = 30_000_000; -/// DOMAIN_APPLICATION_BUILDER. +/// `DOMAIN_APPLICATION_BUILDER`. /// See . const REGISTRATION_DOMAIN_TYPE: DomainType = [0x00, 0x00, 0x00, 0x01]; @@ -50,10 +50,10 @@ fn execution_address_from_str(addr: &str) -> Result { } /// Returns the validator registration signature domain. -// DOMAIN_APPLICATION_BUILDER uses GENESIS_FORK_VERSION to compute domain. -// Refer: -// - https://github.com/ethereum/builder-specs/blob/100d4faf32e5dc672c963741769390ff09ab194a/specs/bellatrix/builder.md#signing -// - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#compute_domain +/// `DOMAIN_APPLICATION_BUILDER` uses `GENESIS_FORK_VERSION` to compute domain. +/// Refer: +/// - https://github.com/ethereum/builder-specs/blob/100d4faf32e5dc672c963741769390ff09ab194a/specs/bellatrix/builder.md#signing +/// - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#compute_domain fn get_registration_domain(genesis_fork_version: Version) -> Domain { let fork_data = ForkData { current_version: genesis_fork_version, @@ -166,8 +166,7 @@ mod tests { .unwrap(); let secret: pluto_crypto::types::PrivateKey = sk_bytes.as_slice().try_into().unwrap(); - let tbls = BlstImpl; - let pubkey = tbls.secret_to_public_key(&secret).unwrap(); + let pubkey = BlstImpl.secret_to_public_key(&secret).unwrap(); let registration_json = r#" { From c02d598ee7e06719a00da9d82c2f55652a391344 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Fri, 20 Feb 2026 17:58:11 +0900 Subject: [PATCH 3/4] fix: fmt --- crates/eth2util/src/lib.rs | 4 ++-- crates/eth2util/src/registration.rs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/eth2util/src/lib.rs b/crates/eth2util/src/lib.rs index 0bd7bd0f..00ebb955 100644 --- a/crates/eth2util/src/lib.rs +++ b/crates/eth2util/src/lib.rs @@ -22,10 +22,10 @@ pub mod deposit; /// Ethereum address helpers. pub mod helpers; -/// Validator registration for builder API. -pub mod registration; /// EIP-2335 keystore management. pub mod keystore; +/// Validator registration for builder API. +pub mod registration; /// Utilities. pub(crate) mod utils; diff --git a/crates/eth2util/src/registration.rs b/crates/eth2util/src/registration.rs index 1a733f5b..72b8e8c8 100644 --- a/crates/eth2util/src/registration.rs +++ b/crates/eth2util/src/registration.rs @@ -236,7 +236,8 @@ mod tests { .try_into() .unwrap(); - tbls.verify(&pubkey, &signing_root, &signature) + BlstImpl + .verify(&pubkey, &signing_root, &signature) .expect("BLS signature verification failed"); } } From 8a48fe38589af2c6f8f0c66f9a3643e54e8a0f07 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Sat, 21 Feb 2026 11:24:03 +0900 Subject: [PATCH 4/4] fix: address comments --- crates/eth2util/src/registration.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/eth2util/src/registration.rs b/crates/eth2util/src/registration.rs index 72b8e8c8..4cab0b29 100644 --- a/crates/eth2util/src/registration.rs +++ b/crates/eth2util/src/registration.rs @@ -122,14 +122,12 @@ mod tests { let timestamp = 946_684_800u64; let result = new_message(pubkey, fee_recipient, gas_limit, timestamp); - assert!(result.is_err()); - assert!( - result - .as_ref() - .err() - .map(std::string::ToString::to_string) - .is_some_and(|err| err.contains("invalid fee recipient address")) - ); + assert!(matches!( + result, + Err(RegistrationError::InvalidAddress( + crate::helpers::HelperError::InvalidAddress(_) + )) + )); } #[test]