diff --git a/Cargo.lock b/Cargo.lock index 1d6a8d0..32a8aa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -834,7 +834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" dependencies = [ "memchr", - "regex-automata 0.4.9", + "regex-automata", "serde", ] @@ -2927,7 +2927,7 @@ dependencies = [ "petgraph", "pico-args", "regex", - "regex-syntax 0.8.5", + "regex-syntax", "string_cache", "term", "tiny-keccak", @@ -2941,7 +2941,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" dependencies = [ - "regex-automata 0.4.9", + "regex-automata", ] [[package]] @@ -3111,11 +3111,11 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -3301,12 +3301,11 @@ checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" dependencies = [ - "overload", - "winapi", + "windows-sys 0.52.0", ] [[package]] @@ -3458,12 +3457,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "pairing" version = "0.23.0" @@ -3837,7 +3830,7 @@ dependencies = [ "rand 0.8.5", "rand_chacha 0.3.1", "rand_xorshift", - "regex-syntax 0.8.5", + "regex-syntax", "rusty-fork", "tempfile", "unarray", @@ -4007,17 +4000,8 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", + "regex-automata", + "regex-syntax", ] [[package]] @@ -4028,15 +4012,9 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - [[package]] name = "regex-syntax" version = "0.8.5" @@ -5427,14 +5405,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -5884,7 +5862,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/README.md b/README.md index 40a5a00..46f67f4 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,24 @@ Generate key and deposit data with a new mnemonic: ./target/debug/eth-staking-smith new-mnemonic --chain mainnet --keystore_password testtest --num_validators 1 ``` +### EIP-7251 Type 2 Compounding Validators + +Generate validators with variable deposits up to 2048 ETH. + +``` +# Default 32 ETH compounding validator +./target/debug/eth-staking-smith new-mnemonic --chain mainnet --keystore_password testtest --num_validators 1 --compounding + +# Type 2 compounding validator with 100 ETH +./target/debug/eth-staking-smith new-mnemonic --chain mainnet --keystore_password testtest --num_validators 1 --deposit_amount_eth 100 --compounding + +# Maximum 2048 ETH compounding validator +./target/debug/eth-staking-smith new-mnemonic --chain mainnet --keystore_password testtest --num_validators 1 --deposit_amount_eth 2048 --compounding + +# Legacy 32 ETH validator with BLS withdrawal credentials +./target/debug/eth-staking-smith new-mnemonic --chain mainnet --keystore_password testtest --num_validators 1 --withdrawal_credentials "0x00abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456" +``` + ## Existing mnemonic Regenerate key and deposit data with existing mnemonic: diff --git a/deny.toml b/deny.toml index 67c7008..3fe02a3 100644 --- a/deny.toml +++ b/deny.toml @@ -4,6 +4,8 @@ ignore = [ "RUSTSEC-2024-0388", # lighthouse depends on paste "RUSTSEC-2024-0436", + # httpmock will replace async-std in 0.8.0 + "RUSTSEC-2025-0052", ] [sources] @@ -33,7 +35,7 @@ allow = [ # # Most of crates above are coming from lighthouse # where they keep repository license of Apache 2.0 -# https://github.com/sigp/lighthouse/blob/291146eeb4fea4bbe0aa3c6aa37eadd566d7e1d4/LICENSE +# https://github.com/sigp/lighthouse/blob/291146eeb4fea4bbe0aa3c6aa37eadd566d7e1d4/LICENSE [[licenses.clarify]] crate = "merkle_proof" expression = "Apache-2.0" diff --git a/src/cli/existing_mnemonic.rs b/src/cli/existing_mnemonic.rs index a71c2c5..665d27b 100644 --- a/src/cli/existing_mnemonic.rs +++ b/src/cli/existing_mnemonic.rs @@ -45,12 +45,12 @@ pub struct ExistingMnemonicSubcommandOpts { pub validator_start_index: Option, /// If this field is set and valid, the given - /// value will be used to set the - /// withdrawal credentials. Otherwise, it will - /// generate withdrawal credentials with the - /// mnemonic-derived withdrawal public key. Valid formats are - /// ^(0x[a-fA-F0-9]{40})$ for execution addresses, - /// ^(0x01[0]{22}[a-fA-F0-9]{40})$ for execution withdrawal credentials + /// value will be used to set the withdrawal credentials. + /// When --compounding is specified, execution addresses and 0x01 + /// credentials will be converted to 0x02 compounding credentials. + /// Valid formats are ^(0x[a-fA-F0-9]{40})$ for execution addresses, + /// ^(0x01[0]{22}[a-fA-F0-9]{40})$ for execution withdrawal credentials, + /// ^(0x02[a-fA-F0-9]{62})$ for EIP-7251 compounding withdrawal credentials (supports variable deposits up to 2048 ETH), /// and ^(0x00[a-fA-F0-9]{62})$ for BLS withdrawal credentials. #[arg(long, visible_alias = "withdrawal_credentials")] pub withdrawal_credentials: Option, @@ -70,6 +70,20 @@ pub struct ExistingMnemonicSubcommandOpts { /// A version of CLI to include into generated deposit data #[arg(long, visible_alias = "deposit_cli_version", default_value = "2.7.0")] pub deposit_cli_version: String, + + /// Deposit amount in ETH. + /// For standard validators: exactly 32 ETH. + /// For EIP-7251 compounding validators (0x02 withdrawal credentials): 32 to 2048 ETH. + #[arg(long, visible_alias = "deposit_amount", default_value = "32")] + pub deposit_amount_eth: u64, + + /// Use EIP-7251 compounding withdrawal credentials (0x02). + /// + /// When enabled, validators will use 0x02 withdrawal credentials which support + /// compounding rewards and variable deposit amounts up to 2048 ETH. + /// When disabled, validators use traditional 0x00 BLS withdrawal credentials. + #[arg(long)] + pub compounding: bool, } impl ExistingMnemonicSubcommandOpts { @@ -93,14 +107,15 @@ impl ExistingMnemonicSubcommandOpts { password, Some(self.num_validators), self.validator_start_index, - self.withdrawal_credentials.is_none(), + self.withdrawal_credentials.is_none() || self.compounding, self.kdf.clone(), ); let export: serde_json::Value = validators .export( chain, self.withdrawal_credentials.clone(), - 32_000_000_000, + self.deposit_amount_eth * 1_000_000_000, // Convert ETH to Gwei + self.compounding, self.deposit_cli_version.clone(), self.testnet_config.clone(), ) diff --git a/src/cli/new_mnemonic.rs b/src/cli/new_mnemonic.rs index d3d811c..e192406 100644 --- a/src/cli/new_mnemonic.rs +++ b/src/cli/new_mnemonic.rs @@ -34,12 +34,12 @@ pub struct NewMnemonicSubcommandOpts { pub validator_start_index: Option, /// If this field is set and valid, the given - /// value will be used to set the - /// withdrawal credentials. Otherwise, it will - /// generate withdrawal credentials with the - /// mnemonic-derived withdrawal public key. Valid formats are - /// ^(0x[a-fA-F0-9]{40})$ for execution addresses, - /// ^(0x01[0]{22}[a-fA-F0-9]{40})$ for execution withdrawal credentials + /// value will be used to set the withdrawal credentials. + /// When --compounding is specified, execution addresses and 0x01 + /// credentials will be converted to 0x02 compounding credentials. + /// Valid formats are ^(0x[a-fA-F0-9]{40})$ for execution addresses, + /// ^(0x01[0]{22}[a-fA-F0-9]{40})$ for execution withdrawal credentials, + /// ^(0x02[a-fA-F0-9]{62})$ for EIP-7251 compounding withdrawal credentials (supports variable deposits up to 2048 ETH), /// and ^(0x00[a-fA-F0-9]{62})$ for BLS withdrawal credentials. #[arg(long, visible_alias = "withdrawal_credentials")] pub withdrawal_credentials: Option, @@ -59,6 +59,20 @@ pub struct NewMnemonicSubcommandOpts { /// A version of CLI to include into generated deposit data #[arg(long, visible_alias = "deposit_cli_version", default_value = "2.7.0")] pub deposit_cli_version: String, + + /// Deposit amount in ETH. + /// For standard validators: exactly 32 ETH. + /// For EIP-7251 compounding validators (0x02 withdrawal credentials): 32 to 2048 ETH. + #[arg(long, visible_alias = "deposit_amount", default_value = "32")] + pub deposit_amount_eth: u64, + + /// Use EIP-7251 compounding withdrawal credentials (0x02). + /// + /// When enabled, validators will use 0x02 withdrawal credentials which support + /// compounding rewards and variable deposit amounts up to 2048 ETH. + /// When disabled, validators use traditional 0x00 BLS withdrawal credentials. + #[arg(long)] + pub compounding: bool, } impl NewMnemonicSubcommandOpts { @@ -82,14 +96,15 @@ impl NewMnemonicSubcommandOpts { password, Some(self.num_validators), None, - self.withdrawal_credentials.is_none(), + self.withdrawal_credentials.is_none() || self.compounding, self.kdf.clone(), ); let export: serde_json::Value = validators .export( chain, self.withdrawal_credentials.clone(), - 32_000_000_000, + self.deposit_amount_eth * 1_000_000_000, // Convert ETH to Gwei + self.compounding, self.deposit_cli_version.clone(), self.testnet_config.clone(), ) diff --git a/src/deposit.rs b/src/deposit.rs index 9738081..f1e9dd5 100644 --- a/src/deposit.rs +++ b/src/deposit.rs @@ -5,6 +5,7 @@ use crate::{ chain_spec::{chain_spec_for_network, chain_spec_from_file}, key_material::VotingKeyMaterial, networks::SupportedNetworks, + utils::{is_compounding_withdrawal_credentials, validate_deposit_amount}, }; #[derive(Debug, Eq, PartialEq)] @@ -36,12 +37,13 @@ pub(crate) fn keystore_to_deposit( )); }; - // For simplicity, support only 32Eth deposits - if deposit_amount_gwei != 32_000_000_000 { - return Err(DepositError::InvalidDepositAmount( - "Invalid amount of deposit data, should be 32Eth".to_string(), - )); - }; + // Validate deposit amount based on withdrawal credentials type + let withdrawal_creds_hex = hex::encode(withdrawal_credentials); + let is_compounding = is_compounding_withdrawal_credentials(&withdrawal_creds_hex); + + if let Err(msg) = validate_deposit_amount(deposit_amount_gwei, is_compounding) { + return Err(DepositError::InvalidDepositAmount(msg)); + } let spec = match network { Some(chain) => chain_spec_for_network(&chain)?, diff --git a/src/utils.rs b/src/utils.rs index 17747c5..81fce10 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -45,6 +45,56 @@ pub fn withdrawal_creds_from_pk(withdrawal_pk: &PublicKeyBytes) -> String { hex::encode(withdrawal_creds) } +/// Creates 0x02 compounding withdrawal credentials from BLS public key (EIP-7251) +/// +/// Used for type 2 validators that support compounding rewards up to 2048 ETH +pub fn compounding_withdrawal_creds_from_pk(withdrawal_pk: &PublicKeyBytes) -> String { + let withdrawal_creds = get_withdrawal_credentials(withdrawal_pk, 2); + hex::encode(withdrawal_creds) +} + +/// Validates deposit amount according to Ethereum staking rules +/// +/// Post-Pectra (EIP-7251): 32 ETH to 2048 ETH for compounding validators +/// Pre-Pectra: Only 32 ETH allowed +pub fn validate_deposit_amount(amount_gwei: u64, is_compounding: bool) -> Result<(), String> { + const MIN_DEPOSIT_GWEI: u64 = 32_000_000_000; // 32 ETH + const MAX_EFFECTIVE_BALANCE: u64 = 2_048_000_000_000; // 2048 ETH + + if amount_gwei < MIN_DEPOSIT_GWEI { + return Err(format!( + "Deposit amount must be at least 32 ETH (got {} Gwei)", + amount_gwei + )); + } + + if is_compounding { + if amount_gwei > MAX_EFFECTIVE_BALANCE { + return Err(format!( + "Compounding validator deposit amount cannot exceed 2048 ETH (got {} Gwei)", + amount_gwei + )); + } + } else if amount_gwei != MIN_DEPOSIT_GWEI { + return Err(format!( + "Non-compounding validator deposit amount must be exactly 32 ETH (got {} Gwei)", + amount_gwei + )); + } + + Ok(()) +} + +/// Determines if withdrawal credentials indicate a compounding validator (0x02 prefix) +pub fn is_compounding_withdrawal_credentials(withdrawal_credentials: &str) -> bool { + let creds = if let Some(stripped) = withdrawal_credentials.strip_prefix("0x") { + stripped + } else { + withdrawal_credentials + }; + creds.starts_with("02") +} + // Various regexes used for input validation lazy_static::lazy_static! { /// see format of execution address: https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/validator.md#eth1_address_withdrawal_prefix @@ -52,4 +102,6 @@ lazy_static::lazy_static! { pub static ref EXECUTION_CREDS_REGEX: Regex = Regex::new(r"^(0x01[0]{22}[a-fA-F0-9]{40})$").unwrap(); pub static ref BLS_CREDS_REGEX: Regex = Regex::new(r"^(0x00[a-fA-F0-9]{62})$").unwrap(); + /// EIP-7251 compounding withdrawal credentials pattern + pub static ref COMPOUNDING_CREDS_REGEX: Regex = Regex::new(r"^(0x02[a-fA-F0-9]{62})$").unwrap(); } diff --git a/src/validators.rs b/src/validators.rs index 6845560..26f2478 100644 --- a/src/validators.rs +++ b/src/validators.rs @@ -19,6 +19,7 @@ const ETH1_CREDENTIALS_PREFIX: &[u8] = &[ 48, 49, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, ]; const ETH2_CREDENTIALS_PREFIX: &[u8] = &[48, 48]; +const COMPOUNDING_CREDENTIALS_PREFIX: &[u8] = &[48, 50]; // "02" in ASCII pub struct Validators { mnemonic_phrase: String, @@ -61,12 +62,13 @@ impl DepositExport { && !withdrawal_credentials .as_bytes() .starts_with(ETH2_CREDENTIALS_PREFIX) + && !withdrawal_credentials + .as_bytes() + .starts_with(COMPOUNDING_CREDENTIALS_PREFIX) { panic!("withdrawal address has unexpected prefix"); } - assert_eq!(32000000000, self.amount); - let pubkey = PublicKey::from_str(&format!("0x{}", pub_key)).expect("could not parse public key"); let pubkey_bytes = PublicKeyBytes::from_str(&format!("0x{}", pub_key)) @@ -231,6 +233,7 @@ impl Validators { network: Option, withdrawal_credentials: Option, deposit_amount_gwei: u64, + compounding: bool, deposit_cli_version: String, chain_spec_file: Option, ) -> Result { @@ -251,6 +254,7 @@ impl Validators { let withdrawal_credentials = set_withdrawal_credentials( withdrawal_credentials.clone(), key_with_store.withdrawal_keypair.clone(), + compounding, )?; let public_key = key_with_store.keypair.pk.as_hex_string().replace("0x", ""); @@ -295,9 +299,61 @@ impl Validators { fn set_withdrawal_credentials( existing_withdrawal_credentials: Option, derived_withdrawal_credentials: Option, + compounding: bool, ) -> Result, DepositError> { - let withdrawal_credentials = match existing_withdrawal_credentials { - Some(creds) => { + let withdrawal_credentials = match (existing_withdrawal_credentials, compounding) { + // If compounding flag is set with explicit credentials, convert them to 0x02 format + (Some(creds), true) => { + if crate::utils::EXECUTION_ADDR_REGEX.is_match(creds.as_str()) { + // Convert execution address to 0x02 compounding credentials + let mut formatted_creds = COMPOUNDING_CREDENTIALS_PREFIX.to_vec(); + formatted_creds.extend_from_slice(&creds.as_bytes()[2..]); + // Pad with zeros to make it 64 hex chars (32 bytes) + while formatted_creds.len() < 64 { + formatted_creds.insert(2, b'0'); + } + hex::decode(formatted_creds).expect("could not decode hex address") + } else if crate::utils::EXECUTION_CREDS_REGEX.is_match(creds.as_str()) { + // Convert 0x01 execution credentials to 0x02 compounding credentials + let mut creds_bytes = + hex::decode(&creds.as_bytes()[2..]).expect("could not decode hex"); + creds_bytes[0] = 0x02; // Change prefix from 0x01 to 0x02 + creds_bytes + } else if crate::utils::COMPOUNDING_CREDS_REGEX.is_match(creds.as_str()) { + // Already compounding credentials, use as-is + hex::decode(&creds.as_bytes()[2..]).expect("could not decode hex") + } else if crate::utils::BLS_CREDS_REGEX.is_match(creds.as_str()) { + // BLS credentials can't be converted to compounding, fall back to derived + let withdrawal_pk = match derived_withdrawal_credentials { + Some(pk) => pk.pk, + None => { + return Err(DepositError::InvalidWithdrawalCredentials( + "Could not retrieve withdrawal public key from key material" + .to_string(), + )) + } + }; + get_withdrawal_credentials(&withdrawal_pk.into(), 2) + } else { + return Err(DepositError::InvalidWithdrawalCredentials( + "Invalid withdrawal address: Please pass in a valid execution address, execution, BLS, or compounding (0x02) credentials with the correct format".to_string(), + )); + } + } + // If compounding flag is set without explicit credentials, generate 0x02 from derived key + (None, true) => { + let withdrawal_pk = match derived_withdrawal_credentials { + Some(pk) => pk.pk, + None => { + return Err(DepositError::InvalidWithdrawalCredentials( + "Could not retrieve withdrawal public key from key material".to_string(), + )) + } + }; + get_withdrawal_credentials(&withdrawal_pk.into(), 2) + } + // If no compounding flag and explicit credentials provided, use them as-is + (Some(creds), false) => { let withdrawal_credentials = if crate::utils::EXECUTION_ADDR_REGEX .is_match(creds.as_str()) { @@ -306,28 +362,30 @@ fn set_withdrawal_credentials( formatted_creds } else if crate::utils::EXECUTION_CREDS_REGEX.is_match(creds.as_str()) || crate::utils::BLS_CREDS_REGEX.is_match(creds.as_str()) + || crate::utils::COMPOUNDING_CREDS_REGEX.is_match(creds.as_str()) { // see format of execution & bls credentials https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/validator.md#bls_withdrawal_prefix + // and EIP-7251 compounding credentials let formatted_creds = creds.as_bytes()[2..].to_vec(); formatted_creds } else { return Err(DepositError::InvalidWithdrawalCredentials( - "Invalid withdrawal address: Please pass in a valid execution address, execution or BLS credentials with the correct format".to_string(), + "Invalid withdrawal address: Please pass in a valid execution address, execution, BLS, or compounding (0x02) credentials with the correct format".to_string(), )); }; hex::decode(withdrawal_credentials).expect("could not decode hex address ") } - None => { + // If no compounding flag and no explicit credentials, use 0x00 BLS + (None, false) => { let withdrawal_pk = match derived_withdrawal_credentials { Some(pk) => pk.pk, None => { return Err(DepositError::InvalidWithdrawalCredentials( - "Could not retrieve withdrawal public key from key matieral".to_string(), + "Could not retrieve withdrawal public key from key material".to_string(), )) } }; - get_withdrawal_credentials(&withdrawal_pk.into(), 0) } }; @@ -370,6 +428,7 @@ mod test { Some(SupportedNetworks::Mainnet), Some("0x0000000000000000000000000000000000000001".to_string()), 32_000_000_000, + false, "2.7.0".to_string(), None, ) @@ -379,6 +438,7 @@ mod test { Some(SupportedNetworks::Mainnet), Some("0x0000000000000000000000000000000000000001".to_string()), 32_000_000_000, + false, "2.7.0".to_string(), None, ) @@ -429,6 +489,7 @@ mod test { Some(SupportedNetworks::Mainnet), Some("0x0000000000000000000000000000000000000001".to_string()), 32_000_000_000, + false, "2.7.0".to_string(), None, ) @@ -438,6 +499,7 @@ mod test { Some(SupportedNetworks::Mainnet), Some("0x0000000000000000000000000000000000000001".to_string()), 32_000_000_000, + false, "2.7.0".to_string(), None, ) @@ -488,6 +550,7 @@ mod test { Some(SupportedNetworks::Mainnet), Some("0x0000000000000000000000000000000000000001".to_string()), 32_000_000_000, + false, "2.7.0".to_string(), None, ) @@ -497,6 +560,7 @@ mod test { Some(SupportedNetworks::Mainnet), Some("0x0000000000000000000000000000000000000001".to_string()), 32_000_000_000, + false, "2.7.0".to_string(), None, ) @@ -539,6 +603,7 @@ mod test { Some(SupportedNetworks::Mainnet), Some("0x0000000000000000000000000000000000000001".to_string()), 32_000_000_000, + false, "2.7.0".to_string(), None, ) @@ -548,6 +613,7 @@ mod test { Some(SupportedNetworks::Mainnet), Some("0x0000000000000000000000000000000000000001".to_string()), 32_000_000_000, + false, "2.7.0".to_string(), None, ) @@ -587,6 +653,7 @@ mod test { Some(SupportedNetworks::Mainnet), None, 32_000_000_000, + false, "2.7.0".to_string(), None, ) @@ -615,6 +682,7 @@ mod test { let response = set_withdrawal_credentials( Some("0x01D4BB555d3B0D7fF17c606161B44E372689C14F4B".to_string()), None, + false, ); assert!(response.is_err()); } @@ -624,6 +692,7 @@ mod test { let response = set_withdrawal_credentials( Some("0xD4BB555d3B0D7fF17c606161B44E372689C14F4B".to_string()), None, + false, ); assert!(response.is_ok()); } @@ -633,6 +702,7 @@ mod test { let response = set_withdrawal_credentials( Some("0x0100000000000000000000000000000000000000000000000000000000000001".to_string()), None, + false, ); assert!(response.is_ok()); @@ -644,6 +714,7 @@ mod test { let response = set_withdrawal_credentials( Some("0x45b91b2f60b88e7392d49ae1364b55e713d06f30e563f9f99e10994b26221d".to_string()), None, + false, ); assert!(response.is_err()); } @@ -653,6 +724,7 @@ mod test { let response = set_withdrawal_credentials( Some("0x0045b91b2f60b88e7392d49ae1364b55e713d06f30e563f9f99e10994b26221d".to_string()), None, + false, ); assert!(response.is_ok()); } @@ -660,14 +732,14 @@ mod test { #[test] fn set_withdrawal_credentials_error_no_key() { // either withdrawal public key or withdrawal credentials must be provided - let response = set_withdrawal_credentials(None, None); + let response = set_withdrawal_credentials(None, None, false); assert!(response.is_err()); } #[test] fn set_withdrawal_credentials_from_public_key() { let keypair = Keypair::random(); - let response = set_withdrawal_credentials(None, Some(keypair)); + let response = set_withdrawal_credentials(None, Some(keypair), false); assert!(response.is_ok()); } } diff --git a/tests/e2e/compounding_validators.rs b/tests/e2e/compounding_validators.rs new file mode 100644 index 0000000..d0ba3d6 --- /dev/null +++ b/tests/e2e/compounding_validators.rs @@ -0,0 +1,378 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DepositDataJson { + pub pubkey: String, + pub withdrawal_credentials: String, + pub amount: u64, + pub signature: String, + pub deposit_message_root: String, + pub deposit_data_root: String, + pub fork_version: String, + pub network_name: String, + pub deposit_cli_version: String, +} + +use assert_cmd::prelude::*; +use eth_staking_smith::{ + utils::{ + compounding_withdrawal_creds_from_pk, is_compounding_withdrawal_credentials, + validate_deposit_amount, + }, + ValidatorExports, +}; +use std::process::Command; +use std::str::FromStr; +use types::PublicKeyBytes; + +#[test] +fn test_deposit_amount_validation_compounding() { + // Valid compounding validator amounts + assert!(validate_deposit_amount(32_000_000_000, true).is_ok()); // 32 ETH + assert!(validate_deposit_amount(64_000_000_000, true).is_ok()); // 64 ETH + assert!(validate_deposit_amount(1_000_000_000_000, true).is_ok()); // 1000 ETH + assert!(validate_deposit_amount(2_048_000_000_000, true).is_ok()); // 2048 ETH (max) + + // Invalid compounding validator amounts + assert!(validate_deposit_amount(31_999_999_999, true).is_err()); + assert!(validate_deposit_amount(2_048_000_000_001, true).is_err()); + assert!(validate_deposit_amount(0, true).is_err()); +} + +#[test] +fn test_deposit_amount_validation_non_compounding() { + // Only 32 ETH allowed for non-compounding validators + assert!(validate_deposit_amount(32_000_000_000, false).is_ok()); // Exactly 32 ETH + + // All other amounts should fail + assert!(validate_deposit_amount(31_999_999_999, false).is_err()); // Just under 32 ETH + assert!(validate_deposit_amount(32_000_000_001, false).is_err()); // Just over 32 ETH + assert!(validate_deposit_amount(64_000_000_000, false).is_err()); // 64 ETH + assert!(validate_deposit_amount(0, false).is_err()); // Zero +} + +#[test] +fn test_is_compounding_withdrawal_credentials() { + // Test 0x02 prefixed credentials (compounding) + assert!(is_compounding_withdrawal_credentials( + "02abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + )); + assert!(is_compounding_withdrawal_credentials( + "0x02abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + )); + + // Test non-compounding credentials + assert!(!is_compounding_withdrawal_credentials( + "00abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + )); // 0x00 + assert!(!is_compounding_withdrawal_credentials( + "01000000000000000000000071c7656ec7ab88b098defb751b7401b5f6d8976f" + )); // 0x01 + assert!(!is_compounding_withdrawal_credentials( + "03abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + )); // 0x03 +} + +#[test] +fn test_compounding_withdrawal_creds_generation() { + // Test generating 0x02 withdrawal credentials from public key + // Using a valid 48-byte (96 hex char) BLS public key from test data + let test_pubkey_str = "0x8c239d313e3f4efb1ed937e7560dfaabeb6def6b88001357d5e9a3c33fdb022f7b028085c09451667f06b6b849c71ce8"; + let pubkey = PublicKeyBytes::from_str(test_pubkey_str).expect("Valid public key"); + + let compounding_creds = compounding_withdrawal_creds_from_pk(&pubkey); + + assert!(compounding_creds.starts_with("02")); + assert_eq!(compounding_creds.len(), 64); + assert!(hex::decode(&compounding_creds).is_ok()); +} + +/* + Test CLI with explicit --compounding flag - should generate 0x02 compounding validators +*/ +#[test] +#[serial_test::serial] +fn test_cli_explicit_compounding_validator() -> Result<(), Box> { + let expected_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + std::env::set_var("MNEMONIC", expected_mnemonic); + + let mut cmd = Command::cargo_bin("eth-staking-smith")?; + cmd.args(&[ + "existing-mnemonic", + "--chain", + "holesky", + "--num_validators", + "1", + "--compounding", + ]); + + let output = cmd.output()?; + let stdout = String::from_utf8(output.stdout)?; + + // Parse the JSON output + let validator_exports: ValidatorExports = serde_json::from_str(&stdout)?; + + // Verify we got one validator + assert_eq!(validator_exports.deposit_data.len(), 1); + + // Verify --compounding flag generates 0x02 credentials + let deposit_data = &validator_exports.deposit_data[0]; + assert!( + deposit_data.withdrawal_credentials.starts_with("02"), + "--compounding flag should generate 0x02 withdrawal credentials, got: {}", + deposit_data.withdrawal_credentials + ); + + // Verify default 32 ETH amount + assert_eq!(deposit_data.amount, 32_000_000_000); + + Ok(()) +} + +/* + Test CLI with custom ETH deposit amount +*/ +#[test] +#[serial_test::serial] +fn test_cli_custom_eth_amount() -> Result<(), Box> { + let expected_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + std::env::set_var("MNEMONIC", expected_mnemonic); + + let test_amounts = vec![64, 100, 500, 2048]; + + for eth_amount in test_amounts { + let mut cmd = Command::cargo_bin("eth-staking-smith")?; + cmd.args(&[ + "existing-mnemonic", + "--chain", + "holesky", + "--num_validators", + "1", + "--deposit_amount", + ð_amount.to_string(), + "--compounding", + ]); + + let output = cmd.output()?; + let stdout = String::from_utf8(output.stdout)?; + + // Parse the JSON output + let validator_exports: ValidatorExports = serde_json::from_str(&stdout)?; + + // Verify the deposit amount is correctly set (ETH converted to Gwei) + let expected_gwei = eth_amount * 1_000_000_000; + let deposit_data = &validator_exports.deposit_data[0]; + assert_eq!( + deposit_data.amount, expected_gwei, + "Expected {} ETH ({} Gwei), got {} Gwei", + eth_amount, expected_gwei, deposit_data.amount + ); + + // Should still generate 0x02 credentials by default + assert!(deposit_data.withdrawal_credentials.starts_with("02")); + } + + Ok(()) +} + +/* + Test CLI with explicit legacy BLS withdrawal credentials +*/ +#[test] +#[serial_test::serial] +fn test_cli_legacy_bls_credentials() -> Result<(), Box> { + let expected_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + std::env::set_var("MNEMONIC", expected_mnemonic); + + let bls_credentials = "0x0012345678901234567890123456789012345678901234567890123456789012"; + + let mut cmd = Command::cargo_bin("eth-staking-smith")?; + cmd.args(&[ + "existing-mnemonic", + "--chain", + "holesky", + "--num_validators", + "1", + "--withdrawal_credentials", + bls_credentials, + ]); + + let output = cmd.output()?; + let stdout = String::from_utf8(output.stdout)?; + + // Parse the JSON output + let validator_exports: ValidatorExports = serde_json::from_str(&stdout)?; + + // Verify the withdrawal credentials are set correctly + let deposit_data = &validator_exports.deposit_data[0]; + assert_eq!( + deposit_data.withdrawal_credentials, + "0012345678901234567890123456789012345678901234567890123456789012" + ); + + // Should be 32 ETH (only amount allowed for non-compounding) + assert_eq!(deposit_data.amount, 32_000_000_000); + + Ok(()) +} + +/* + Test CLI boundary conditions - minimum and maximum deposits +*/ +#[test] +#[serial_test::serial] +fn test_cli_boundary_conditions() -> Result<(), Box> { + let expected_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + std::env::set_var("MNEMONIC", expected_mnemonic); + + // Test minimum (32 ETH) + let mut cmd = Command::cargo_bin("eth-staking-smith")?; + cmd.args(&[ + "existing-mnemonic", + "--chain", + "holesky", + "--num_validators", + "1", + "--deposit_amount", + "32", + ]); + + let output = cmd.output()?; + assert!(output.status.success(), "32 ETH deposit should succeed"); + + let stdout = String::from_utf8(output.stdout)?; + let validator_exports: ValidatorExports = serde_json::from_str(&stdout)?; + assert_eq!(validator_exports.deposit_data[0].amount, 32_000_000_000); + + // Test maximum (2048 ETH) - requires compounding + let mut cmd = Command::cargo_bin("eth-staking-smith")?; + cmd.args(&[ + "existing-mnemonic", + "--chain", + "holesky", + "--num_validators", + "1", + "--deposit_amount", + "2048", + "--compounding", + ]); + + let output = cmd.output()?; + assert!(output.status.success(), "2048 ETH deposit should succeed"); + + let stdout = String::from_utf8(output.stdout)?; + let validator_exports: ValidatorExports = serde_json::from_str(&stdout)?; + assert_eq!(validator_exports.deposit_data[0].amount, 2_048_000_000_000); + + Ok(()) +} + +/* + Test that new-mnemonic command uses 0x00 credentials by default (backward compatibility) +*/ +#[test] +fn test_cli_new_mnemonic_default_bls() -> Result<(), Box> { + let mut cmd = Command::cargo_bin("eth-staking-smith")?; + cmd.args(&[ + "new-mnemonic", + "--chain", + "holesky", + "--num_validators", + "1", + ]); + + let output = cmd.output()?; + let stdout = String::from_utf8(output.stdout)?; + + // Parse the JSON output + let validator_exports: ValidatorExports = serde_json::from_str(&stdout)?; + + // Verify default behavior generates 0x00 BLS credentials (backward compatibility) + let deposit_data = &validator_exports.deposit_data[0]; + assert!( + deposit_data.withdrawal_credentials.starts_with("00"), + "New mnemonic should generate 0x00 BLS withdrawal credentials by default, got: {}", + deposit_data.withdrawal_credentials + ); + + Ok(()) +} + +/* + Test multiple validators with compounding amounts +*/ +#[test] +#[serial_test::serial] +fn test_cli_multiple_compounding_validators() -> Result<(), Box> { + let expected_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + std::env::set_var("MNEMONIC", expected_mnemonic); + + let mut cmd = Command::cargo_bin("eth-staking-smith")?; + cmd.args(&[ + "existing-mnemonic", + "--chain", + "holesky", + "--num_validators", + "3", + "--deposit_amount", + "100", + "--compounding", + ]); + + let output = cmd.output()?; + let stdout = String::from_utf8(output.stdout)?; + + // Parse the JSON output + let validator_exports: ValidatorExports = serde_json::from_str(&stdout)?; + + // Verify we got 3 validators + assert_eq!(validator_exports.deposit_data.len(), 3); + + // Verify all have correct amount and compounding credentials + for (i, deposit_data) in validator_exports.deposit_data.iter().enumerate() { + assert_eq!( + deposit_data.amount, 100_000_000_000, + "Validator {} should have 100 ETH", + i + ); + assert!( + deposit_data.withdrawal_credentials.starts_with("02"), + "Validator {} should have 0x02 credentials", + i + ); + } + + Ok(()) +} + +/* + Test that new-mnemonic command uses 0x02 credentials with --compounding flag +*/ +#[test] +fn test_cli_new_mnemonic_compounding_flag() -> Result<(), Box> { + let mut cmd = Command::cargo_bin("eth-staking-smith")?; + cmd.args(&[ + "new-mnemonic", + "--chain", + "holesky", + "--num_validators", + "1", + "--compounding", + ]); + + let output = cmd.output()?; + let stdout = String::from_utf8(output.stdout)?; + + // Parse the JSON output + let validator_exports: ValidatorExports = serde_json::from_str(&stdout)?; + + // Verify --compounding flag generates 0x02 credentials + let deposit_data = &validator_exports.deposit_data[0]; + assert!( + deposit_data.withdrawal_credentials.starts_with("02"), + "New mnemonic with --compounding should generate 0x02 withdrawal credentials, got: {}", + deposit_data.withdrawal_credentials + ); + + Ok(()) +} diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 1fe9a26..ffebe04 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -1,5 +1,6 @@ mod batch_presigned_exit_message; mod bls_to_execution_change; +mod compounding_validators; mod existing_mnemonic; mod new_mnemonic; mod presigned_exit_message;