From a50c3a7e897f598a8608e01bc8239cd145c0537b Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:48:31 -0700 Subject: [PATCH 1/6] fix(consensus): accept validator pubkey for ownership transfer --- bin/tempo/src/tempo_cmd.rs | 127 +++++++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 12 deletions(-) diff --git a/bin/tempo/src/tempo_cmd.rs b/bin/tempo/src/tempo_cmd.rs index c1620ba80c..34f04f13ff 100644 --- a/bin/tempo/src/tempo_cmd.rs +++ b/bin/tempo/src/tempo_cmd.rs @@ -9,7 +9,7 @@ use std::{ use alloy::hex::ToHexExt; use alloy_network::EthereumWallet; -use alloy_primitives::{Address, B256, Bytes}; +use alloy_primitives::{Address, B256, Bytes, keccak256}; use alloy_provider::{Provider, ProviderBuilder}; use alloy_rpc_types_eth::TransactionRequest; use alloy_signer_aws::{AwsSigner, aws_config, aws_sdk_kms}; @@ -31,6 +31,7 @@ use eyre::{OptionExt as _, Report, WrapErr as _, bail, eyre}; use reth_chainspec::EthChainSpec; use reth_cli_runner::CliRunner; use reth_ethereum_cli::ExtendedCommand; +use secp256k1::PublicKey as Secp256k1PublicKey; use serde::Serialize; use tempo_alloy::TempoNetwork; use tempo_chainspec::spec::{TempoChainSpec, TempoChainSpecParser}; @@ -522,14 +523,47 @@ impl AddValidator { } } +#[derive(Debug, clap::Args)] +#[group(required = true, multiple = false)] +pub(crate) struct NewValidatorOwnershipArgs { + /// Path to the file holding the private key of the new validator address. + #[arg(long, value_name = "FILE")] + new_private_key: Option, + + /// Hex-encoded secp256k1 public key for the new validator address. + #[arg(long, value_name = "PUBLIC_KEY")] + new_public_key: Option, +} + +impl NewValidatorOwnershipArgs { + fn resolve_address(&self) -> eyre::Result
{ + match (&self.new_private_key, &self.new_public_key) { + (Some(path), None) => { + let signer = key_from_file(path).wrap_err_with(|| { + format!("failed reading private key from file `{}`", path.display()) + })?; + + Ok(signer.address()) + } + (None, Some(public_key)) => address_from_public_key(public_key), + (Some(_), Some(_)) => { + bail!("only one of --new-private-key or --new-public-key may be provided") + } + (None, None) => { + bail!("either --new-private-key or --new-public-key must be provided") + } + } + } +} + #[derive(Debug, clap::Args)] pub(crate) struct TransferValidatorOwnership { /// Validator ethereum address, ed25519 public key, or index #[arg()] id: ValidatorId, - /// Path to the file holding the private key of the new validator address - #[arg(long, value_name = "FILE")] - new_private_key: PathBuf, + + #[command(flatten)] + new_validator: NewValidatorOwnershipArgs, #[command(flatten)] submit: ValidatorTransactionArgs, @@ -539,14 +573,7 @@ impl TransferValidatorOwnership { async fn run(self) -> eyre::Result<()> { let provider = self.submit.provider().await?; - let new_signer = key_from_file(&self.new_private_key).wrap_err_with(|| { - format!( - "failed reading private key from file `{}`", - self.new_private_key.display() - ) - })?; - - let new_validator_address = new_signer.address(); + let new_validator_address = self.new_validator.resolve_address()?; let validator = read_validator_from_contract(&provider, self.id).await?; @@ -1201,12 +1228,27 @@ fn key_from_file>(p: P) -> eyre::Result { .wrap_err("failed converting file decoded hex bytes to private key") } +fn address_from_public_key(public_key: &str) -> eyre::Result
{ + let public_key = public_key.trim(); + let public_key = public_key + .strip_prefix("0x") + .or_else(|| public_key.strip_prefix("0X")) + .unwrap_or(public_key); + let bytes = alloy::hex::decode(public_key).wrap_err("failed decoding public key from hex")?; + let public_key = + Secp256k1PublicKey::from_slice(&bytes).wrap_err("failed parsing secp256k1 public key")?; + let uncompressed = public_key.serialize_uncompressed(); + + Ok(Address::from_slice(&keccak256(&uncompressed[1..])[12..])) +} + #[cfg(test)] mod tests { use super::*; use clap::Parser; use reth_ethereum_cli::Cli; use reth_rpc_server_types::{RethRpcModule, RpcModuleSelection, RpcModuleValidator}; + use secp256k1::{PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey}; use tempo_chainspec::spec::TempoChainSpecParser; type TempoCli = Cli< @@ -1268,6 +1310,67 @@ mod tests { assert!(result.is_err()); } + #[test] + fn parse_transfer_validator_ownership_with_new_public_key() { + let cli = TempoCli::try_parse_from([ + "tempo", + "consensus", + "transfer-validator-ownership", + "0", + "--new-public-key", + "0x045474b1fd8f9a4f6847507d8aa2f209f856c727c4641e57b09f00f5f20e141c2ac3f3af0e7e8b3558dcfff6c5b3388c711050602b15f422ff2b61cd5bc80c2f2a", + "--wallet-key", + "/tmp/wallet.key", + "--yes", + ]) + .unwrap(); + + assert!(matches!( + cli.command, + reth_ethereum::cli::Commands::Ext(TempoSubcommand::Consensus( + ConsensusSubcommand::TransferValidatorOwnership(_) + )) + )); + } + + #[test] + fn parse_transfer_validator_ownership_rejects_both_new_key_inputs() { + let result = TempoCli::try_parse_from([ + "tempo", + "consensus", + "transfer-validator-ownership", + "0", + "--new-private-key", + "/tmp/new.key", + "--new-public-key", + "0x045474b1fd8f9a4f6847507d8aa2f209f856c727c4641e57b09f00f5f20e141c2ac3f3af0e7e8b3558dcfff6c5b3388c711050602b15f422ff2b61cd5bc80c2f2a", + "--wallet-key", + "/tmp/wallet.key", + "--yes", + ]); + + assert!(result.is_err()); + } + + #[test] + fn resolves_new_validator_address_from_public_key() { + let secret_key = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let expected = PrivateKeySigner::from_slice(&secret_key.secret_bytes()) + .unwrap() + .address(); + let public_key = Secp256k1PublicKey::from_secret_key(&Secp256k1::new(), &secret_key); + let public_key = alloy::hex::encode_prefixed(public_key.serialize_uncompressed()); + + let actual = NewValidatorOwnershipArgs { + new_private_key: None, + new_public_key: Some(public_key), + } + .resolve_address() + .unwrap(); + + assert_eq!(actual, expected); + } + #[test] fn tempo_rpc_module_validator_allows_tempo_custom_modules() { for module in ["consensus", "operator", "tempo", "token"] { From 549c112ae0c4b391d8e7911089d41e3a6bd36890 Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Fri, 1 May 2026 09:43:55 +0000 Subject: [PATCH 2/6] docs: add preference comments to transfer-validator-ownership key args Amp-Thread-ID: https://ampcode.com/threads/T-019de2e7-c1cb-7534-ba88-8ad3413a9c3d Co-authored-by: Amp --- bin/tempo/src/tempo_cmd.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bin/tempo/src/tempo_cmd.rs b/bin/tempo/src/tempo_cmd.rs index 34f04f13ff..174e041808 100644 --- a/bin/tempo/src/tempo_cmd.rs +++ b/bin/tempo/src/tempo_cmd.rs @@ -527,10 +527,14 @@ impl AddValidator { #[group(required = true, multiple = false)] pub(crate) struct NewValidatorOwnershipArgs { /// Path to the file holding the private key of the new validator address. + /// Preferred over `--new-public-key` because it ensures the caller has + /// control over the new validator's private key. #[arg(long, value_name = "FILE")] new_private_key: Option, /// Hex-encoded secp256k1 public key for the new validator address. + /// Note: `--new-private-key` is preferred because it ensures the caller + /// has control over the new validator's private key. #[arg(long, value_name = "PUBLIC_KEY")] new_public_key: Option, } From 9e281e02dad59f4062fda82d35c8d59e99f9ea90 Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Fri, 1 May 2026 09:45:02 +0000 Subject: [PATCH 3/6] refactor: replace manual bail arms with unreachable in resolve_address Amp-Thread-ID: https://ampcode.com/threads/T-019de2e7-c1cb-7534-ba88-8ad3413a9c3d Co-authored-by: Amp --- bin/tempo/src/tempo_cmd.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/bin/tempo/src/tempo_cmd.rs b/bin/tempo/src/tempo_cmd.rs index 174e041808..a590e0d645 100644 --- a/bin/tempo/src/tempo_cmd.rs +++ b/bin/tempo/src/tempo_cmd.rs @@ -550,12 +550,7 @@ impl NewValidatorOwnershipArgs { Ok(signer.address()) } (None, Some(public_key)) => address_from_public_key(public_key), - (Some(_), Some(_)) => { - bail!("only one of --new-private-key or --new-public-key may be provided") - } - (None, None) => { - bail!("either --new-private-key or --new-public-key must be provided") - } + _ => unreachable!("exclusivity enforced by clap arg group"), } } } From 3a33d0b01f3af3f4c9c0a8f6c3f13aa6fa87c052 Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Fri, 1 May 2026 10:03:25 +0000 Subject: [PATCH 4/6] refactor: drop manual 0x prefix stripping in address_from_public_key Amp-Thread-ID: https://ampcode.com/threads/T-019de2e7-c1cb-7534-ba88-8ad3413a9c3d Co-authored-by: Amp --- bin/tempo/src/tempo_cmd.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/bin/tempo/src/tempo_cmd.rs b/bin/tempo/src/tempo_cmd.rs index a590e0d645..ccee2f779d 100644 --- a/bin/tempo/src/tempo_cmd.rs +++ b/bin/tempo/src/tempo_cmd.rs @@ -1228,12 +1228,8 @@ fn key_from_file>(p: P) -> eyre::Result { } fn address_from_public_key(public_key: &str) -> eyre::Result
{ - let public_key = public_key.trim(); - let public_key = public_key - .strip_prefix("0x") - .or_else(|| public_key.strip_prefix("0X")) - .unwrap_or(public_key); - let bytes = alloy::hex::decode(public_key).wrap_err("failed decoding public key from hex")?; + let bytes = + alloy::hex::decode(public_key.trim()).wrap_err("failed decoding public key from hex")?; let public_key = Secp256k1PublicKey::from_slice(&bytes).wrap_err("failed parsing secp256k1 public key")?; let uncompressed = public_key.serialize_uncompressed(); From d20cbafad4e3b864e0dfcea2f0a149a7f4c377a4 Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Fri, 1 May 2026 10:05:22 +0000 Subject: [PATCH 5/6] refactor: replace --new-public-key with --new-validator-address Amp-Thread-ID: https://ampcode.com/threads/T-019de2e7-c1cb-7534-ba88-8ad3413a9c3d Co-authored-by: Amp --- bin/tempo/src/tempo_cmd.rs | 54 ++++++++++++++------------------------ 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/bin/tempo/src/tempo_cmd.rs b/bin/tempo/src/tempo_cmd.rs index ccee2f779d..7b5349a5ac 100644 --- a/bin/tempo/src/tempo_cmd.rs +++ b/bin/tempo/src/tempo_cmd.rs @@ -9,7 +9,7 @@ use std::{ use alloy::hex::ToHexExt; use alloy_network::EthereumWallet; -use alloy_primitives::{Address, B256, Bytes, keccak256}; +use alloy_primitives::{Address, B256, Bytes}; use alloy_provider::{Provider, ProviderBuilder}; use alloy_rpc_types_eth::TransactionRequest; use alloy_signer_aws::{AwsSigner, aws_config, aws_sdk_kms}; @@ -31,7 +31,7 @@ use eyre::{OptionExt as _, Report, WrapErr as _, bail, eyre}; use reth_chainspec::EthChainSpec; use reth_cli_runner::CliRunner; use reth_ethereum_cli::ExtendedCommand; -use secp256k1::PublicKey as Secp256k1PublicKey; + use serde::Serialize; use tempo_alloy::TempoNetwork; use tempo_chainspec::spec::{TempoChainSpec, TempoChainSpecParser}; @@ -527,21 +527,21 @@ impl AddValidator { #[group(required = true, multiple = false)] pub(crate) struct NewValidatorOwnershipArgs { /// Path to the file holding the private key of the new validator address. - /// Preferred over `--new-public-key` because it ensures the caller has - /// control over the new validator's private key. + /// Preferred over `--new-validator-address` because it ensures the caller + /// has control over the new validator's private key. #[arg(long, value_name = "FILE")] new_private_key: Option, - /// Hex-encoded secp256k1 public key for the new validator address. + /// Ethereum address of the new validator. /// Note: `--new-private-key` is preferred because it ensures the caller /// has control over the new validator's private key. - #[arg(long, value_name = "PUBLIC_KEY")] - new_public_key: Option, + #[arg(long, value_name = "ADDRESS")] + new_validator_address: Option
, } impl NewValidatorOwnershipArgs { fn resolve_address(&self) -> eyre::Result
{ - match (&self.new_private_key, &self.new_public_key) { + match (&self.new_private_key, &self.new_validator_address) { (Some(path), None) => { let signer = key_from_file(path).wrap_err_with(|| { format!("failed reading private key from file `{}`", path.display()) @@ -549,7 +549,7 @@ impl NewValidatorOwnershipArgs { Ok(signer.address()) } - (None, Some(public_key)) => address_from_public_key(public_key), + (None, Some(address)) => Ok(*address), _ => unreachable!("exclusivity enforced by clap arg group"), } } @@ -1227,23 +1227,12 @@ fn key_from_file>(p: P) -> eyre::Result { .wrap_err("failed converting file decoded hex bytes to private key") } -fn address_from_public_key(public_key: &str) -> eyre::Result
{ - let bytes = - alloy::hex::decode(public_key.trim()).wrap_err("failed decoding public key from hex")?; - let public_key = - Secp256k1PublicKey::from_slice(&bytes).wrap_err("failed parsing secp256k1 public key")?; - let uncompressed = public_key.serialize_uncompressed(); - - Ok(Address::from_slice(&keccak256(&uncompressed[1..])[12..])) -} - #[cfg(test)] mod tests { use super::*; use clap::Parser; use reth_ethereum_cli::Cli; use reth_rpc_server_types::{RethRpcModule, RpcModuleSelection, RpcModuleValidator}; - use secp256k1::{PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey}; use tempo_chainspec::spec::TempoChainSpecParser; type TempoCli = Cli< @@ -1306,14 +1295,14 @@ mod tests { } #[test] - fn parse_transfer_validator_ownership_with_new_public_key() { + fn parse_transfer_validator_ownership_with_new_validator_address() { let cli = TempoCli::try_parse_from([ "tempo", "consensus", "transfer-validator-ownership", "0", - "--new-public-key", - "0x045474b1fd8f9a4f6847507d8aa2f209f856c727c4641e57b09f00f5f20e141c2ac3f3af0e7e8b3558dcfff6c5b3388c711050602b15f422ff2b61cd5bc80c2f2a", + "--new-validator-address", + "0x0000000000000000000000000000000000000001", "--wallet-key", "/tmp/wallet.key", "--yes", @@ -1329,7 +1318,7 @@ mod tests { } #[test] - fn parse_transfer_validator_ownership_rejects_both_new_key_inputs() { + fn parse_transfer_validator_ownership_rejects_both_inputs() { let result = TempoCli::try_parse_from([ "tempo", "consensus", @@ -1337,8 +1326,8 @@ mod tests { "0", "--new-private-key", "/tmp/new.key", - "--new-public-key", - "0x045474b1fd8f9a4f6847507d8aa2f209f856c727c4641e57b09f00f5f20e141c2ac3f3af0e7e8b3558dcfff6c5b3388c711050602b15f422ff2b61cd5bc80c2f2a", + "--new-validator-address", + "0x0000000000000000000000000000000000000001", "--wallet-key", "/tmp/wallet.key", "--yes", @@ -1348,17 +1337,14 @@ mod tests { } #[test] - fn resolves_new_validator_address_from_public_key() { - let secret_key = SecretKey::from_slice(&[1u8; 32]).unwrap(); - let expected = PrivateKeySigner::from_slice(&secret_key.secret_bytes()) - .unwrap() - .address(); - let public_key = Secp256k1PublicKey::from_secret_key(&Secp256k1::new(), &secret_key); - let public_key = alloy::hex::encode_prefixed(public_key.serialize_uncompressed()); + fn resolves_new_validator_address_directly() { + let expected: Address = "0x0000000000000000000000000000000000000001" + .parse() + .unwrap(); let actual = NewValidatorOwnershipArgs { new_private_key: None, - new_public_key: Some(public_key), + new_validator_address: Some(expected), } .resolve_address() .unwrap(); From 57cf353096f6525c4f07bf249c3bf49c62ff2eb1 Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Fri, 1 May 2026 10:31:35 +0000 Subject: [PATCH 6/6] chore: remove transfer-validator-ownership tests Amp-Thread-ID: https://ampcode.com/threads/T-019de2e7-c1cb-7534-ba88-8ad3413a9c3d Co-authored-by: Amp --- bin/tempo/src/tempo_cmd.rs | 58 -------------------------------------- 1 file changed, 58 deletions(-) diff --git a/bin/tempo/src/tempo_cmd.rs b/bin/tempo/src/tempo_cmd.rs index 7b5349a5ac..5c7b2bfa33 100644 --- a/bin/tempo/src/tempo_cmd.rs +++ b/bin/tempo/src/tempo_cmd.rs @@ -1294,64 +1294,6 @@ mod tests { assert!(result.is_err()); } - #[test] - fn parse_transfer_validator_ownership_with_new_validator_address() { - let cli = TempoCli::try_parse_from([ - "tempo", - "consensus", - "transfer-validator-ownership", - "0", - "--new-validator-address", - "0x0000000000000000000000000000000000000001", - "--wallet-key", - "/tmp/wallet.key", - "--yes", - ]) - .unwrap(); - - assert!(matches!( - cli.command, - reth_ethereum::cli::Commands::Ext(TempoSubcommand::Consensus( - ConsensusSubcommand::TransferValidatorOwnership(_) - )) - )); - } - - #[test] - fn parse_transfer_validator_ownership_rejects_both_inputs() { - let result = TempoCli::try_parse_from([ - "tempo", - "consensus", - "transfer-validator-ownership", - "0", - "--new-private-key", - "/tmp/new.key", - "--new-validator-address", - "0x0000000000000000000000000000000000000001", - "--wallet-key", - "/tmp/wallet.key", - "--yes", - ]); - - assert!(result.is_err()); - } - - #[test] - fn resolves_new_validator_address_directly() { - let expected: Address = "0x0000000000000000000000000000000000000001" - .parse() - .unwrap(); - - let actual = NewValidatorOwnershipArgs { - new_private_key: None, - new_validator_address: Some(expected), - } - .resolve_address() - .unwrap(); - - assert_eq!(actual, expected); - } - #[test] fn tempo_rpc_module_validator_allows_tempo_custom_modules() { for module in ["consensus", "operator", "tempo", "token"] {