From be648d950c55da653715aa2caa11382f6e1996b3 Mon Sep 17 00:00:00 2001 From: kevaundray Date: Thu, 16 Oct 2025 11:21:15 +0100 Subject: [PATCH] feat: Stateless validation function receives public keys corresponding to each transaction (#17841) Co-authored-by: Wolfgang Welz --- Cargo.lock | 2 + Cargo.toml | 2 +- crates/stateless/Cargo.toml | 8 ++ crates/stateless/src/lib.rs | 3 + crates/stateless/src/recover_block.rs | 130 ++++++++++++++++++ crates/stateless/src/validation.rs | 18 ++- testing/ef-tests/Cargo.toml | 2 +- testing/ef-tests/src/cases/blockchain_test.rs | 38 ++++- 8 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 crates/stateless/src/recover_block.rs diff --git a/Cargo.lock b/Cargo.lock index f9d8401ec7..336442241a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10400,6 +10400,7 @@ dependencies = [ "alloy-rpc-types-debug", "alloy-trie", "itertools 0.14.0", + "k256", "reth-chainspec", "reth-consensus", "reth-errors", @@ -10410,6 +10411,7 @@ dependencies = [ "reth-revm", "reth-trie-common", "reth-trie-sparse", + "secp256k1 0.30.0", "serde", "serde_with", "thiserror 2.0.16", diff --git a/Cargo.toml b/Cargo.toml index 68dc13584f..a781e3b604 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -446,7 +446,7 @@ reth-rpc-convert = { path = "crates/rpc/rpc-convert" } reth-stages = { path = "crates/stages/stages" } reth-stages-api = { path = "crates/stages/api" } reth-stages-types = { path = "crates/stages/types", default-features = false } -reth-stateless = { path = "crates/stateless" } +reth-stateless = { path = "crates/stateless", default-features = false } reth-static-file = { path = "crates/static-file/static-file" } reth-static-file-types = { path = "crates/static-file/types", default-features = false } reth-storage-api = { path = "crates/storage/storage-api", default-features = false } diff --git a/crates/stateless/Cargo.toml b/crates/stateless/Cargo.toml index 36a891ac3d..8adbae28ae 100644 --- a/crates/stateless/Cargo.toml +++ b/crates/stateless/Cargo.toml @@ -36,3 +36,11 @@ thiserror.workspace = true itertools.workspace = true serde.workspace = true serde_with.workspace = true + +k256 = { workspace = true, optional = true } +secp256k1 = { workspace = true, optional = true } + +[features] +default = ["k256"] +k256 = ["dep:k256"] +secp256k1 = ["dep:secp256k1"] diff --git a/crates/stateless/src/lib.rs b/crates/stateless/src/lib.rs index 1e858b9f9f..6813638485 100644 --- a/crates/stateless/src/lib.rs +++ b/crates/stateless/src/lib.rs @@ -35,9 +35,12 @@ extern crate alloc; +mod recover_block; /// Sparse trie implementation for stateless validation pub mod trie; +#[doc(inline)] +pub use recover_block::UncompressedPublicKey; #[doc(inline)] pub use trie::StatelessTrie; #[doc(inline)] diff --git a/crates/stateless/src/recover_block.rs b/crates/stateless/src/recover_block.rs new file mode 100644 index 0000000000..b402cb3724 --- /dev/null +++ b/crates/stateless/src/recover_block.rs @@ -0,0 +1,130 @@ +use crate::validation::StatelessValidationError; +use alloc::vec::Vec; +use alloy_consensus::BlockHeader; +use alloy_primitives::{Address, Signature, B256}; +use reth_chainspec::EthereumHardforks; +use reth_ethereum_primitives::{Block, TransactionSigned}; +use reth_primitives_traits::{Block as _, RecoveredBlock}; + +#[cfg(all(feature = "k256", feature = "secp256k1"))] +use k256 as _; + +/// Serialized uncompressed public key +pub type UncompressedPublicKey = [u8; 65]; + +/// Verifies all transactions in a block against a list of public keys and signatures. +/// +/// Returns a `RecoveredBlock` +pub(crate) fn recover_block_with_public_keys( + block: Block, + public_keys: Vec, + chain_spec: &ChainSpec, +) -> Result, StatelessValidationError> +where + ChainSpec: EthereumHardforks, +{ + if block.body().transactions.len() != public_keys.len() { + return Err(StatelessValidationError::Custom( + "Number of public keys must match number of transactions", + )); + } + + // Determine if we're in the Homestead fork for signature validation + let is_homestead = chain_spec.is_homestead_active_at_block(block.header().number()); + + // Verify each transaction signature against its corresponding public key + let senders = public_keys + .iter() + .zip(block.body().transactions()) + .map(|(vk, tx)| verify_and_compute_sender(vk, tx, is_homestead)) + .collect::, _>>()?; + + // Create RecoveredBlock with verified senders + let block_hash = block.hash_slow(); + Ok(RecoveredBlock::new(block, senders, block_hash)) +} + +/// Verifies a transaction using its signature and the given public key. +/// +/// Note: If the signature or the public key is incorrect, then this method +/// will return an error. +/// +/// Returns the address derived from the public key. +fn verify_and_compute_sender( + vk: &UncompressedPublicKey, + tx: &TransactionSigned, + is_homestead: bool, +) -> Result { + let sig = tx.signature(); + + // non-normalized signatures are only valid pre-homestead + let sig_is_normalized = sig.normalize_s().is_none(); + if is_homestead && !sig_is_normalized { + return Err(StatelessValidationError::HomesteadSignatureNotNormalized); + } + let sig_hash = tx.signature_hash(); + #[cfg(all(feature = "k256", feature = "secp256k1"))] + { + let _ = verify_and_compute_sender_unchecked_k256; + } + #[cfg(feature = "secp256k1")] + { + verify_and_compute_sender_unchecked_secp256k1(vk, sig, sig_hash) + } + #[cfg(all(feature = "k256", not(feature = "secp256k1")))] + { + verify_and_compute_sender_unchecked_k256(vk, sig, sig_hash) + } + #[cfg(not(any(feature = "secp256k1", feature = "k256")))] + { + let _ = vk; + let _ = tx; + let _: B256 = sig_hash; + let _: &Signature = sig; + + unimplemented!("Must choose either k256 or secp256k1 feature") + } +} +#[cfg(feature = "k256")] +fn verify_and_compute_sender_unchecked_k256( + vk: &UncompressedPublicKey, + sig: &Signature, + sig_hash: B256, +) -> Result { + use k256::ecdsa::{signature::hazmat::PrehashVerifier, VerifyingKey}; + + let vk = + VerifyingKey::from_sec1_bytes(vk).map_err(|_| StatelessValidationError::SignerRecovery)?; + + sig.to_k256() + .and_then(|sig| vk.verify_prehash(sig_hash.as_slice(), &sig)) + .map_err(|_| StatelessValidationError::SignerRecovery)?; + + Ok(Address::from_public_key(&vk)) +} + +#[cfg(feature = "secp256k1")] +fn verify_and_compute_sender_unchecked_secp256k1( + vk: &UncompressedPublicKey, + sig: &Signature, + sig_hash: B256, +) -> Result { + use secp256k1::{ecdsa::Signature as SecpSignature, Message, PublicKey, SECP256K1}; + + let public_key = + PublicKey::from_slice(vk).map_err(|_| StatelessValidationError::SignerRecovery)?; + + let mut sig_bytes = [0u8; 64]; + sig_bytes[0..32].copy_from_slice(&sig.r().to_be_bytes::<32>()); + sig_bytes[32..64].copy_from_slice(&sig.s().to_be_bytes::<32>()); + + let signature = SecpSignature::from_compact(&sig_bytes) + .map_err(|_| StatelessValidationError::SignerRecovery)?; + + let message = Message::from_digest(sig_hash.0); + SECP256K1 + .verify_ecdsa(&message, &signature, &public_key) + .map_err(|_| StatelessValidationError::SignerRecovery)?; + + Ok(Address::from_raw_public_key(&vk[1..])) +} diff --git a/crates/stateless/src/validation.rs b/crates/stateless/src/validation.rs index 38b96d6bd0..a0475b0993 100644 --- a/crates/stateless/src/validation.rs +++ b/crates/stateless/src/validation.rs @@ -1,4 +1,5 @@ use crate::{ + recover_block::{recover_block_with_public_keys, UncompressedPublicKey}, trie::{StatelessSparseTrie, StatelessTrie}, witness_db::WitnessDatabase, ExecutionWitness, @@ -89,6 +90,14 @@ pub enum StatelessValidationError { expected: B256, }, + /// Error during signer recovery. + #[error("signer recovery failed")] + SignerRecovery, + + /// Error when signature has non-normalized s value in homestead block. + #[error("signature s value not normalized for homestead block")] + HomesteadSignatureNotNormalized, + /// Custom error. #[error("{0}")] Custom(&'static str), @@ -130,7 +139,8 @@ pub enum StatelessValidationError { /// If all steps succeed the function returns `Some` containing the hash of the validated /// `current_block`. pub fn stateless_validation( - current_block: RecoveredBlock, + current_block: Block, + public_keys: Vec, witness: ExecutionWitness, chain_spec: Arc, evm_config: E, @@ -141,6 +151,7 @@ where { stateless_validation_with_trie::( current_block, + public_keys, witness, chain_spec, evm_config, @@ -154,7 +165,8 @@ where /// /// See `stateless_validation` for detailed documentation of the validation process. pub fn stateless_validation_with_trie( - current_block: RecoveredBlock, + current_block: Block, + public_keys: Vec, witness: ExecutionWitness, chain_spec: Arc, evm_config: E, @@ -164,6 +176,8 @@ where ChainSpec: Send + Sync + EthChainSpec
+ EthereumHardforks + Debug, E: ConfigureEvm + Clone + 'static, { + let current_block = recover_block_with_public_keys(current_block, public_keys, &*chain_spec)?; + let mut ancestor_headers: Vec<_> = witness .headers .iter() diff --git a/testing/ef-tests/Cargo.toml b/testing/ef-tests/Cargo.toml index 6b11e29c70..745172cd82 100644 --- a/testing/ef-tests/Cargo.toml +++ b/testing/ef-tests/Cargo.toml @@ -28,7 +28,7 @@ reth-evm.workspace = true reth-evm-ethereum.workspace = true reth-ethereum-consensus.workspace = true reth-revm = { workspace = true, features = ["std", "witness"] } -reth-stateless = { workspace = true } +reth-stateless = { workspace = true, features = ["secp256k1"] } reth-tracing.workspace = true reth-trie.workspace = true reth-trie-db.workspace = true diff --git a/testing/ef-tests/src/cases/blockchain_test.rs b/testing/ef-tests/src/cases/blockchain_test.rs index 0526efaa6e..5519846458 100644 --- a/testing/ef-tests/src/cases/blockchain_test.rs +++ b/testing/ef-tests/src/cases/blockchain_test.rs @@ -10,17 +10,20 @@ use reth_chainspec::ChainSpec; use reth_consensus::{Consensus, HeaderValidator}; use reth_db_common::init::{insert_genesis_hashes, insert_genesis_history, insert_genesis_state}; use reth_ethereum_consensus::{validate_block_post_execution, EthBeaconConsensus}; -use reth_ethereum_primitives::Block; +use reth_ethereum_primitives::{Block, TransactionSigned}; use reth_evm::{execute::Executor, ConfigureEvm}; use reth_evm_ethereum::EthEvmConfig; -use reth_primitives_traits::{RecoveredBlock, SealedBlock}; +use reth_primitives_traits::{Block as BlockTrait, RecoveredBlock, SealedBlock}; use reth_provider::{ test_utils::create_test_provider_factory_with_chain_spec, BlockWriter, DatabaseProviderFactory, ExecutionOutcome, HeaderProvider, HistoryWriter, OriginalValuesKnown, StateProofProvider, StateWriter, StaticFileProviderFactory, StaticFileSegment, StaticFileWriter, }; use reth_revm::{database::StateProviderDatabase, witness::ExecutionWitnessRecord, State}; -use reth_stateless::{validation::stateless_validation, ExecutionWitness}; +use reth_stateless::{ + trie::StatelessSparseTrie, validation::stateless_validation_with_trie, ExecutionWitness, + UncompressedPublicKey, +}; use reth_trie::{HashedPostState, KeccakKeyHasher, StateRoot}; use reth_trie_db::DatabaseStateRoot; use std::{ @@ -356,9 +359,16 @@ fn run_case( } // Now validate using the stateless client if everything else passes - for (block, execution_witness) in &program_inputs { - stateless_validation( - block.clone(), + for (recovered_block, execution_witness) in &program_inputs { + let block = recovered_block.clone().into_block(); + + // Recover the actual public keys from the transaction signatures + let public_keys = recover_signers(block.body().transactions()) + .expect("Failed to recover public keys from transaction signatures"); + + stateless_validation_with_trie::( + block, + public_keys, execution_witness.clone(), chain_spec.clone(), EthEvmConfig::new(chain_spec.clone()), @@ -413,6 +423,22 @@ fn pre_execution_checks( Ok(()) } +/// Recover public keys from transaction signatures. +fn recover_signers<'a, I>(txs: I) -> Result, Box> +where + I: IntoIterator, +{ + txs.into_iter() + .enumerate() + .map(|(i, tx)| { + tx.signature() + .recover_from_prehash(&tx.signature_hash()) + .map(|keys| keys.to_encoded_point(false).as_bytes().try_into().unwrap()) + .map_err(|e| format!("failed to recover signature for tx #{i}: {e}").into()) + }) + .collect::, _>>() +} + /// Returns whether the test at the given path should be skipped. /// /// Some tests are edge cases that cannot happen on mainnet, while others are skipped for