Skip to content

Commit 84b669a

Browse files
committed
fix or add some context for special cases
1 parent 5f1926c commit 84b669a

5 files changed

Lines changed: 317 additions & 3 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ev-revm/src/handler.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,10 @@ where
227227
let mut total_refunded: i64 = 0;
228228
let mut last_result: Option<FrameResult> = None;
229229

230+
// Execute each call in the batch sequentially.
231+
// set_batch_call only modifies (kind, value, data) - the nonce is intentionally
232+
// shared since a batch is a single atomic transaction with one nonce.
233+
// Note: only the first call may be CREATE (enforced in validate_initial_tx_gas).
230234
for call in &calls {
231235
let mut call_tx = base_tx.clone();
232236
call_tx.set_batch_call(call);
@@ -239,6 +243,10 @@ where
239243

240244
if !instruction_result.is_ok() {
241245
evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint);
246+
// For CREATE batches: the checkpoint revert undoes the nonce increment that
247+
// happened during CREATE execution. We must manually re-increment it here
248+
// to match Ethereum's behavior where nonce always increments even on failure.
249+
// For CALL batches: nonce was incremented before checkpoint, so revert preserves it.
242250
if calls
243251
.first()
244252
.map(|call| call.to.is_create())
@@ -581,6 +589,10 @@ where
581589
);
582590
}
583591

592+
// Nonce handling for batches:
593+
// - CALL batches: increment nonce here (standard pre-execution behavior)
594+
// - CREATE batches: nonce is incremented during CREATE frame execution,
595+
// which also uses it for contract address derivation
584596
if is_call {
585597
caller.info.set_nonce(caller.info.nonce.saturating_add(1));
586598
}
@@ -600,6 +612,10 @@ where
600612
);
601613
}
602614

615+
// Note: We deduct effective_gas_price (not max_fee_per_gas) upfront.
616+
// This is safe because effective_gas_price <= max_fee_per_gas by construction,
617+
// and the check above ensures sponsor can cover the worst case (max_gas_cost).
618+
// This approach is more gas-efficient than deducting max upfront and reimbursing.
603619
let effective_gas_price = tx.effective_gas_price(basefee);
604620
let gas_cost = U256::from(tx.gas_limit()).saturating_mul(U256::from(effective_gas_price));
605621
let mut new_sponsor_balance = sponsor_balance.saturating_sub(gas_cost);
@@ -1196,6 +1212,83 @@ mod tests {
11961212
);
11971213
}
11981214

1215+
/// Tests that sponsored transactions with max_fee_per_gas < base_fee are rejected.
1216+
///
1217+
/// This validation happens in revm's `validate_env` (delegated via inner handler)
1218+
/// BEFORE our custom `validate_and_deduct_sponsored_tx` runs. This test serves
1219+
/// as a regression test to ensure EIP-1559 fee validation is not bypassed.
1220+
#[test]
1221+
fn sponsored_tx_rejects_when_max_fee_below_basefee() {
1222+
let caller = address!("0x0000000000000000000000000000000000000aaa");
1223+
let sponsor = address!("0x0000000000000000000000000000000000000bbb");
1224+
1225+
let mut state = State::builder()
1226+
.with_database(CacheDB::<EmptyDB>::default())
1227+
.with_bundle_update()
1228+
.build();
1229+
1230+
state.insert_account(
1231+
caller,
1232+
AccountInfo {
1233+
balance: U256::from(10_000_000_000u64),
1234+
nonce: 0,
1235+
code_hash: KECCAK_EMPTY,
1236+
code: None,
1237+
},
1238+
);
1239+
1240+
// Sponsor has plenty of balance - the rejection should be due to fee, not balance
1241+
state.insert_account(
1242+
sponsor,
1243+
AccountInfo {
1244+
balance: U256::from(100_000_000u64),
1245+
nonce: 0,
1246+
code_hash: KECCAK_EMPTY,
1247+
code: None,
1248+
},
1249+
);
1250+
1251+
let mut evm_env: EvmEnv<SpecId> = EvmEnv::default();
1252+
evm_env.cfg_env.chain_id = 1;
1253+
evm_env.cfg_env.spec = SpecId::CANCUN;
1254+
evm_env.block_env.basefee = 100; // basefee = 100
1255+
evm_env.block_env.gas_limit = 30_000_000;
1256+
evm_env.block_env.number = U256::from(1);
1257+
1258+
let mut evm = EvTxEvmFactory::default().create_evm(state, evm_env);
1259+
1260+
let calls = vec![Call {
1261+
to: TxKind::Call(address!("0x0000000000000000000000000000000000000ccc")),
1262+
value: U256::ZERO,
1263+
input: Bytes::new(),
1264+
}];
1265+
1266+
// max_fee_per_gas (50) < basefee (100)
1267+
let tx_env = TxEnv {
1268+
caller,
1269+
gas_limit: 100_000,
1270+
gas_price: 50,
1271+
gas_priority_fee: Some(1),
1272+
chain_id: Some(1),
1273+
tx_type: TransactionType::Eip1559.into(),
1274+
..Default::default()
1275+
};
1276+
1277+
let tx = EvTxEnv::with_calls_and_sponsor(tx_env, calls, sponsor);
1278+
let result = evm.transact_raw(tx);
1279+
1280+
assert!(
1281+
result.is_err(),
1282+
"Transaction with max_fee < basefee should be rejected"
1283+
);
1284+
1285+
let err = result.unwrap_err();
1286+
assert!(
1287+
matches!(err, EVMError::Transaction(InvalidTransaction::GasPriceLessThanBasefee)),
1288+
"Expected GasPriceLessThanBasefee error, got: {err:?}"
1289+
);
1290+
}
1291+
11991292
#[test]
12001293
fn reject_deploy_for_non_allowlisted_caller() {
12011294
let allowlisted = address!("0x00000000000000000000000000000000000000aa");

crates/node/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ reth-tasks.workspace = true
8989
reth-tracing.workspace = true
9090
tempfile.workspace = true
9191
hex = "0.4"
92+
alloy-rlp.workspace = true
9293

9394
[lints]
9495
workspace = true

crates/node/src/txpool.rs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,13 +398,22 @@ impl<Client> EvTransactionValidator<Client> {
398398
self.validate_evnode_calls(tx)?;
399399

400400
if let Some(signature) = tx.fee_payer_signature.as_ref() {
401+
// Sponsored transaction: validate sponsor balance
401402
let executor = pooled.transaction().signer();
402403
let sponsor = tx.recover_sponsor(executor, signature).map_err(|_| {
403404
InvalidPoolTransactionError::other(EvTxPoolError::InvalidSponsorSignature)
404405
})?;
405406

406407
let gas_cost = U256::from(tx.max_fee_per_gas).saturating_mul(U256::from(tx.gas_limit));
407408
self.validate_sponsor_balance(state, sponsor, gas_cost)?;
409+
} else {
410+
// Non-sponsored EvNode transaction: executor pays gas, validate their balance
411+
if sender_balance < *pooled.cost() {
412+
return Err(InvalidPoolTransactionError::Overdraft {
413+
cost: *pooled.cost(),
414+
balance: sender_balance,
415+
});
416+
}
408417
}
409418

410419
Ok(())
@@ -502,6 +511,10 @@ where
502511
.set_tx_fee_cap(ctx.config().rpc.rpc_tx_fee_cap)
503512
.with_max_tx_gas_limit(ctx.config().txpool.max_tx_gas_limit)
504513
.with_minimum_priority_fee(ctx.config().txpool.minimum_priority_fee)
514+
// Disable the standard caller balance check - we handle balance validation
515+
// in EvTransactionValidator::validate_evnode which checks:
516+
// - Sponsor balance for sponsored EvNode transactions
517+
// - Sender balance for non-sponsored EvNode and standard Ethereum transactions
505518
.disable_balance_check()
506519
.with_additional_tasks(ctx.config().txpool.additional_validation_tasks)
507520
.build_with_tasks::<EvPooledTransaction, _, _>(
@@ -528,3 +541,124 @@ where
528541
Ok(transaction_pool)
529542
}
530543
}
544+
545+
#[cfg(test)]
546+
mod tests {
547+
use super::*;
548+
use alloy_consensus::Signed;
549+
use alloy_eips::eip2930::AccessList;
550+
use alloy_primitives::{Bytes, Signature, TxKind};
551+
use ev_primitives::{Call, EvNodeSignedTx, EvNodeTransaction};
552+
use reth_provider::test_utils::MockEthProvider;
553+
554+
fn sample_signature() -> Signature {
555+
let mut bytes = [0u8; 65];
556+
bytes[64] = 27;
557+
Signature::from_raw_array(&bytes).expect("valid test signature")
558+
}
559+
560+
/// Creates a non-sponsored EvNode transaction (fee_payer_signature = None)
561+
fn create_non_sponsored_evnode_tx(gas_limit: u64, max_fee_per_gas: u128) -> EvNodeSignedTx {
562+
let tx = EvNodeTransaction {
563+
chain_id: 1,
564+
nonce: 0,
565+
max_priority_fee_per_gas: 1,
566+
max_fee_per_gas,
567+
gas_limit,
568+
calls: vec![Call {
569+
to: TxKind::Call(Address::ZERO),
570+
value: U256::ZERO,
571+
input: Bytes::new(),
572+
}],
573+
access_list: AccessList::default(),
574+
fee_payer_signature: None, // Non-sponsored
575+
};
576+
Signed::new_unhashed(tx, sample_signature())
577+
}
578+
579+
fn create_pooled_tx(signed_tx: EvNodeSignedTx, signer: Address) -> EvPooledTransaction {
580+
let envelope = EvTxEnvelope::EvNode(signed_tx);
581+
let recovered = alloy_consensus::transaction::Recovered::new_unchecked(envelope, signer);
582+
let encoded_length = 200; // Approximate length for test
583+
EvPooledTransaction::new(recovered, encoded_length)
584+
}
585+
586+
fn create_test_validator() -> EvTransactionValidator<MockEthProvider> {
587+
use reth_transaction_pool::{
588+
blobstore::InMemoryBlobStore, validate::EthTransactionValidatorBuilder,
589+
};
590+
591+
let provider = MockEthProvider::default();
592+
let blob_store = InMemoryBlobStore::default();
593+
let inner = EthTransactionValidatorBuilder::new(provider)
594+
.no_shanghai()
595+
.no_cancun()
596+
.build(blob_store);
597+
EvTransactionValidator::new(inner)
598+
}
599+
600+
/// Tests that non-sponsored EvNode transactions with insufficient sender balance
601+
/// are rejected with an Overdraft error.
602+
///
603+
/// BUG: Currently this test FAILS because validate_evnode does not check
604+
/// sender balance for non-sponsored EvNode transactions.
605+
#[test]
606+
fn non_sponsored_evnode_rejects_insufficient_balance() {
607+
let validator = create_test_validator();
608+
609+
// Create a non-sponsored EvNode transaction
610+
let gas_limit = 21_000u64;
611+
let max_fee_per_gas = 1_000_000_000u128; // 1 gwei
612+
let signed_tx = create_non_sponsored_evnode_tx(gas_limit, max_fee_per_gas);
613+
614+
let signer = Address::random();
615+
let pooled = create_pooled_tx(signed_tx, signer);
616+
617+
// Sender has ZERO balance - clearly insufficient
618+
let sender_balance = U256::ZERO;
619+
let mut state: Option<Box<dyn AccountInfoReader>> = None;
620+
621+
// Call validate_evnode - should return Overdraft error
622+
let result = validator.validate_evnode(&pooled, sender_balance, &mut state);
623+
624+
assert!(
625+
result.is_err(),
626+
"Non-sponsored EvNode with zero balance should be rejected, but got Ok(())"
627+
);
628+
629+
if let Err(err) = result {
630+
assert!(
631+
matches!(err, InvalidPoolTransactionError::Overdraft { .. }),
632+
"Expected Overdraft error, got: {:?}",
633+
err
634+
);
635+
}
636+
}
637+
638+
/// Tests that non-sponsored EvNode transactions with sufficient balance are accepted.
639+
#[test]
640+
fn non_sponsored_evnode_accepts_sufficient_balance() {
641+
let validator = create_test_validator();
642+
643+
let gas_limit = 21_000u64;
644+
let max_fee_per_gas = 1_000_000_000u128;
645+
let signed_tx = create_non_sponsored_evnode_tx(gas_limit, max_fee_per_gas);
646+
647+
let signer = Address::random();
648+
let pooled = create_pooled_tx(signed_tx, signer);
649+
650+
let tx_cost = *pooled.cost();
651+
652+
// Sender has MORE than enough balance
653+
let sender_balance = tx_cost + U256::from(1);
654+
let mut state: Option<Box<dyn AccountInfoReader>> = None;
655+
656+
let result = validator.validate_evnode(&pooled, sender_balance, &mut state);
657+
658+
assert!(
659+
result.is_ok(),
660+
"Non-sponsored EvNode with sufficient balance should be accepted, got: {:?}",
661+
result
662+
);
663+
}
664+
}

0 commit comments

Comments
 (0)