Skip to content

Commit a2e7600

Browse files
authored
fix: add permissioned EVM validation when gas is specified (#122)
* txpool: enforce deploy allowlist at admission; unify check in one place; add unit test; fix ChainSpec bound in pool builder * chore: refactor to use same check_deploy_allowed fn in both cases * chore: pr cleanup * chore: fmt * ci: clippy * chore: address PR feedback
1 parent 5ec85a0 commit a2e7600

3 files changed

Lines changed: 164 additions & 16 deletions

File tree

crates/ev-revm/src/deploy.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,38 @@ impl DeployAllowlistSettings {
4545
self.allowlist.binary_search(&caller).is_ok()
4646
}
4747
}
48+
49+
/// Error returned by deploy allowlist checks.
50+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51+
pub enum DeployCheckError {
52+
/// Caller is not allowed to perform top-level contract creation.
53+
NotAllowed,
54+
}
55+
56+
// Intentionally no envelope discriminator here to keep dependencies light.
57+
58+
/// Enforces the deploy allowlist policy.
59+
///
60+
/// If `is_top_level_create` is false or settings are None or not active yet, this is a no-op.
61+
/// Otherwise returns `NotAllowed` if `caller` is not in the allowlist.
62+
pub fn check_deploy_allowed(
63+
settings: Option<&DeployAllowlistSettings>,
64+
caller: Address,
65+
is_top_level_create: bool,
66+
block_number: u64,
67+
) -> Result<(), DeployCheckError> {
68+
if !is_top_level_create {
69+
return Ok(());
70+
}
71+
let Some(settings) = settings else {
72+
return Ok(());
73+
};
74+
if !settings.is_active(block_number) {
75+
return Ok(());
76+
}
77+
if settings.is_allowed(caller) {
78+
Ok(())
79+
} else {
80+
Err(DeployCheckError::NotAllowed)
81+
}
82+
}

crates/ev-revm/src/handler.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,14 @@ impl<EVM, ERROR, FRAME> EvHandler<EVM, ERROR, FRAME> {
7878
.number()
7979
.try_into()
8080
.unwrap_or(u64::MAX);
81-
let Some(settings) = self.deploy_allowlist_for_block(block_number) else {
82-
return Ok(());
83-
};
8481
let tx = evm.ctx_ref().tx();
85-
if matches!(tx.kind(), TxKind::Create) && !settings.is_allowed(tx.caller()) {
82+
let caller = tx.caller();
83+
let is_create = matches!(tx.kind(), TxKind::Create);
84+
85+
let settings = self.deploy_allowlist_for_block(block_number);
86+
if let Err(_e) =
87+
crate::deploy::check_deploy_allowed(settings, caller, is_create, block_number)
88+
{
8689
return Err(
8790
<ERROR as reth_revm::revm::context::result::FromStringError>::from_string(
8891
"contract deployment not allowed".to_string(),

crates/node/src/txpool.rs

Lines changed: 122 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ use reth_node_builder::{
2121
BuilderContext,
2222
};
2323
use reth_primitives_traits::NodePrimitives;
24-
use reth_storage_api::{AccountInfoReader, StateProviderFactory};
24+
use reth_storage_api::{AccountInfoReader, BlockNumReader, StateProviderFactory};
2525
use reth_transaction_pool::{
2626
blobstore::DiskFileBlobStore,
2727
error::{InvalidPoolTransactionError, PoolTransactionError},
2828
CoinbaseTipOrdering, EthBlobTransactionSidecar, EthPoolTransaction, EthPooledTransaction,
2929
EthTransactionValidator, PoolTransaction, TransactionOrigin, TransactionValidationOutcome,
3030
TransactionValidationTaskExecutor, TransactionValidator,
3131
};
32-
use tracing::{debug, info};
32+
use tracing::{debug, info, warn};
3333

3434
/// Pool transaction wrapper for `EvTxEnvelope`.
3535
#[derive(Debug, Clone)]
@@ -286,13 +286,19 @@ pub enum EvTxPoolError {
286286
/// Error while querying account info from the state provider.
287287
#[error("state provider error: {0}")]
288288
StateProvider(String),
289+
/// Top-level contract deployment not allowed for caller.
290+
#[error("contract deployment not allowed")]
291+
DeployNotAllowed,
289292
}
290293

291294
impl PoolTransactionError for EvTxPoolError {
292295
fn is_bad_transaction(&self) -> bool {
293296
matches!(
294297
self,
295-
Self::EmptyCalls | Self::InvalidCreatePosition | Self::InvalidSponsorSignature
298+
Self::EmptyCalls
299+
| Self::InvalidCreatePosition
300+
| Self::InvalidSponsorSignature
301+
| Self::DeployNotAllowed
296302
)
297303
}
298304

@@ -305,13 +311,24 @@ impl PoolTransactionError for EvTxPoolError {
305311
#[derive(Debug, Clone)]
306312
pub struct EvTransactionValidator<Client> {
307313
inner: Arc<EthTransactionValidator<Client, EvPooledTransaction>>,
314+
deploy_allowlist: Option<ev_revm::deploy::DeployAllowlistSettings>,
308315
}
309316

310-
impl<Client> EvTransactionValidator<Client> {
317+
impl<Client> EvTransactionValidator<Client>
318+
where
319+
Client: BlockNumReader,
320+
{
311321
/// Wraps the provided Ethereum validator with EV-specific validation logic.
312-
pub fn new(inner: EthTransactionValidator<Client, EvPooledTransaction>) -> Self {
322+
pub fn new(
323+
inner: EthTransactionValidator<Client, EvPooledTransaction>,
324+
deploy_allowlist: Option<ev_revm::deploy::DeployAllowlistSettings>,
325+
) -> Self
326+
where
327+
Client: BlockNumReader,
328+
{
313329
Self {
314330
inner: Arc::new(inner),
331+
deploy_allowlist,
315332
}
316333
}
317334

@@ -383,6 +400,31 @@ impl<Client> EvTransactionValidator<Client> {
383400
where
384401
Client: StateProviderFactory,
385402
{
403+
// Unified deploy allowlist check (covers both Ethereum and EvNode txs).
404+
if let Some(settings) = &self.deploy_allowlist {
405+
let is_top_level_create = match pooled.transaction().inner() {
406+
EvTxEnvelope::Ethereum(tx) => alloy_consensus::Transaction::is_create(tx),
407+
EvTxEnvelope::EvNode(ref signed) => {
408+
let tx = signed.tx();
409+
tx.calls.first().map(|c| c.to.is_create()).unwrap_or(false)
410+
}
411+
};
412+
let caller = pooled.transaction().signer();
413+
let block_number = self.inner.client().best_block_number().map_err(|err| {
414+
InvalidPoolTransactionError::other(EvTxPoolError::StateProvider(err.to_string()))
415+
})?;
416+
if let Err(_e) = ev_revm::deploy::check_deploy_allowed(
417+
Some(settings),
418+
caller,
419+
is_top_level_create,
420+
block_number,
421+
) {
422+
return Err(InvalidPoolTransactionError::other(
423+
EvTxPoolError::DeployNotAllowed,
424+
));
425+
}
426+
}
427+
386428
let consensus = pooled.transaction().inner();
387429
let EvTxEnvelope::EvNode(tx) = consensus else {
388430
if sender_balance < *pooled.cost() {
@@ -422,7 +464,7 @@ impl<Client> EvTransactionValidator<Client> {
422464

423465
impl<Client> TransactionValidator for EvTransactionValidator<Client>
424466
where
425-
Client: ChainSpecProvider<ChainSpec: EthereumHardforks> + StateProviderFactory,
467+
Client: ChainSpecProvider<ChainSpec: EthereumHardforks> + StateProviderFactory + BlockNumReader,
426468
{
427469
type Transaction = EvPooledTransaction;
428470

@@ -470,7 +512,7 @@ pub struct EvolvePoolBuilder;
470512
impl<Types, Node> PoolBuilder<Node> for EvolvePoolBuilder
471513
where
472514
Types: NodeTypes<
473-
ChainSpec: EthereumHardforks,
515+
ChainSpec = reth_chainspec::ChainSpec,
474516
Primitives: NodePrimitives<SignedTx = TransactionSigned>,
475517
>,
476518
Node: FullNodeTypes<Types = Types>,
@@ -521,7 +563,26 @@ where
521563
ctx.task_executor().clone(),
522564
blob_store.clone(),
523565
)
524-
.map(EvTransactionValidator::new);
566+
.map(|inner| {
567+
// Wire deploy-allowlist from chainspec extras into the pool validator.
568+
let evolve_config = crate::config::EvolvePayloadBuilderConfig::from_chain_spec(
569+
ctx.chain_spec().as_ref(),
570+
)
571+
.unwrap_or_else(|err| {
572+
warn!(
573+
target: "reth::cli",
574+
"Failed to parse evolve config from chainspec: {err}"
575+
);
576+
Default::default()
577+
});
578+
let deploy_allowlist =
579+
evolve_config
580+
.deploy_allowlist_settings()
581+
.map(|(allowlist, activation)| {
582+
ev_revm::deploy::DeployAllowlistSettings::new(allowlist, activation)
583+
});
584+
EvTransactionValidator::new(inner, deploy_allowlist)
585+
});
525586

526587
if validator.validator().inner.eip4844() {
527588
let kzg_settings = validator.validator().inner.kzg_settings().clone();
@@ -576,14 +637,38 @@ mod tests {
576637
Signed::new_unhashed(tx, sample_signature())
577638
}
578639

640+
/// Creates a non-sponsored `EvNode` transaction with CREATE as the first call.
641+
fn create_non_sponsored_evnode_create_tx(
642+
gas_limit: u64,
643+
max_fee_per_gas: u128,
644+
) -> EvNodeSignedTx {
645+
let tx = EvNodeTransaction {
646+
chain_id: 1,
647+
nonce: 0,
648+
max_priority_fee_per_gas: 1,
649+
max_fee_per_gas,
650+
gas_limit,
651+
calls: vec![Call {
652+
to: TxKind::Create,
653+
value: U256::ZERO,
654+
input: Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xf3]), // minimal initcode
655+
}],
656+
access_list: AccessList::default(),
657+
fee_payer_signature: None,
658+
};
659+
Signed::new_unhashed(tx, sample_signature())
660+
}
661+
579662
fn create_pooled_tx(signed_tx: EvNodeSignedTx, signer: Address) -> EvPooledTransaction {
580663
let envelope = EvTxEnvelope::EvNode(signed_tx);
581664
let recovered = alloy_consensus::transaction::Recovered::new_unchecked(envelope, signer);
582665
let encoded_length = 200; // Approximate length for test
583666
EvPooledTransaction::new(recovered, encoded_length)
584667
}
585668

586-
fn create_test_validator() -> EvTransactionValidator<MockEthProvider> {
669+
fn create_test_validator(
670+
deploy_allowlist: Option<ev_revm::deploy::DeployAllowlistSettings>,
671+
) -> EvTransactionValidator<MockEthProvider> {
587672
use reth_transaction_pool::{
588673
blobstore::InMemoryBlobStore, validate::EthTransactionValidatorBuilder,
589674
};
@@ -594,7 +679,7 @@ mod tests {
594679
.no_shanghai()
595680
.no_cancun()
596681
.build(blob_store);
597-
EvTransactionValidator::new(inner)
682+
EvTransactionValidator::new(inner, deploy_allowlist)
598683
}
599684

600685
/// Tests that non-sponsored `EvNode` transactions with insufficient sender balance
@@ -604,7 +689,7 @@ mod tests {
604689
/// sender balance for non-sponsored `EvNode` transactions.
605690
#[test]
606691
fn non_sponsored_evnode_rejects_insufficient_balance() {
607-
let validator = create_test_validator();
692+
let validator = create_test_validator(None);
608693

609694
// Create a non-sponsored EvNode transaction
610695
let gas_limit = 21_000u64;
@@ -638,7 +723,7 @@ mod tests {
638723
/// Tests that non-sponsored `EvNode` transactions with sufficient balance are accepted.
639724
#[test]
640725
fn non_sponsored_evnode_accepts_sufficient_balance() {
641-
let validator = create_test_validator();
726+
let validator = create_test_validator(None);
642727

643728
let gas_limit = 21_000u64;
644729
let max_fee_per_gas = 1_000_000_000u128;
@@ -661,4 +746,29 @@ mod tests {
661746
result
662747
);
663748
}
749+
750+
/// Tests pool-level deploy allowlist rejection for `EvNode` CREATE when caller not allowlisted.
751+
#[test]
752+
fn evnode_create_rejected_when_not_allowlisted() {
753+
// Configure deploy allowlist with a different address than the signer
754+
let allowed = Address::from([0x11u8; 20]);
755+
let settings = ev_revm::deploy::DeployAllowlistSettings::new(vec![allowed], 0);
756+
let validator = create_test_validator(Some(settings));
757+
758+
let gas_limit = 200_000u64;
759+
let max_fee_per_gas = 1_000_000_000u128;
760+
let signed_tx = create_non_sponsored_evnode_create_tx(gas_limit, max_fee_per_gas);
761+
762+
let signer = Address::from([0x22u8; 20]); // not allowlisted
763+
let pooled = create_pooled_tx(signed_tx, signer);
764+
765+
let sender_balance = *pooled.cost() + U256::from(1);
766+
let mut state: Option<Box<dyn AccountInfoReader>> = None;
767+
768+
let result = validator.validate_evnode(&pooled, sender_balance, &mut state);
769+
assert!(result.is_err());
770+
if let Err(err) = result {
771+
assert!(matches!(err, InvalidPoolTransactionError::Other(_)));
772+
}
773+
}
664774
}

0 commit comments

Comments
 (0)