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 a8ef52d7..00ebb955 100644 --- a/crates/eth2util/src/lib.rs +++ b/crates/eth2util/src/lib.rs @@ -24,6 +24,8 @@ pub mod helpers; /// 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 new file mode 100644 index 00000000..4cab0b29 --- /dev/null +++ b/crates/eth2util/src/registration.rs @@ -0,0 +1,241 @@ +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!(matches!( + result, + Err(RegistrationError::InvalidAddress( + crate::helpers::HelperError::InvalidAddress(_) + )) + )); + } + + #[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 pubkey = BlstImpl.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(); + + BlstImpl + .verify(&pubkey, &signing_root, &signature) + .expect("BLS signature verification failed"); + } +}