feat: Stateless validation function receives public keys corresponding to each transaction (#17841)

Co-authored-by: Wolfgang Welz <welzwo@gmail.com>
This commit is contained in:
kevaundray
2025-10-16 11:21:15 +01:00
committed by GitHub
parent 5beeaedfae
commit be648d950c
8 changed files with 193 additions and 10 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -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 }

View File

@@ -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"]

View File

@@ -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)]

View File

@@ -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<ChainSpec>(
block: Block,
public_keys: Vec<UncompressedPublicKey>,
chain_spec: &ChainSpec,
) -> Result<RecoveredBlock<Block>, 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::<Result<Vec<_>, _>>()?;
// 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<Address, StatelessValidationError> {
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<Address, StatelessValidationError> {
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<Address, StatelessValidationError> {
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..]))
}

View File

@@ -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<ChainSpec, E>(
current_block: RecoveredBlock<Block>,
current_block: Block,
public_keys: Vec<UncompressedPublicKey>,
witness: ExecutionWitness,
chain_spec: Arc<ChainSpec>,
evm_config: E,
@@ -141,6 +151,7 @@ where
{
stateless_validation_with_trie::<StatelessSparseTrie, ChainSpec, E>(
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<T, ChainSpec, E>(
current_block: RecoveredBlock<Block>,
current_block: Block,
public_keys: Vec<UncompressedPublicKey>,
witness: ExecutionWitness,
chain_spec: Arc<ChainSpec>,
evm_config: E,
@@ -164,6 +176,8 @@ where
ChainSpec: Send + Sync + EthChainSpec<Header = Header> + EthereumHardforks + Debug,
E: ConfigureEvm<Primitives = EthPrimitives> + Clone + 'static,
{
let current_block = recover_block_with_public_keys(current_block, public_keys, &*chain_spec)?;
let mut ancestor_headers: Vec<_> = witness
.headers
.iter()

View File

@@ -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

View File

@@ -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::<StatelessSparseTrie, _, _>(
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<Vec<UncompressedPublicKey>, Box<dyn std::error::Error>>
where
I: IntoIterator<Item = &'a TransactionSigned>,
{
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::<Result<Vec<UncompressedPublicKey>, _>>()
}
/// 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