From c078f9cd5b1513edeaaafb0df26d1bf1e6ec1024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:29:58 +0200 Subject: [PATCH 01/19] Test competing EVM transactions with one nonce Exercise two transactions accepted against the same pre-state nonce and included in one block. The second transaction must become a zero-cost precondition failure without stopping the reactor or changing state. --- .../main_reactor/tests/transactions.rs | 171 +++++++++++++++++- 1 file changed, 169 insertions(+), 2 deletions(-) diff --git a/node/src/reactor/main_reactor/tests/transactions.rs b/node/src/reactor/main_reactor/tests/transactions.rs index ecf28a86cf..41b1640001 100644 --- a/node/src/reactor/main_reactor/tests/transactions.rs +++ b/node/src/reactor/main_reactor/tests/transactions.rs @@ -37,7 +37,7 @@ use casper_types::{ bytesrepr::{Bytes, ToBytes}, evm, execution::ExecutionResultV1, - EvmAddr, EvmConfig, EvmSpec, EvmTransaction, DEFAULT_WEI_PER_MOTE, + EvmAddr, EvmConfig, EvmSpec, EvmTransaction, EvmTransactionError, DEFAULT_WEI_PER_MOTE, }; pub(crate) static ALICE_SECRET_KEY: Lazy> = Lazy::new(|| { @@ -1039,6 +1039,15 @@ fn signed_evm_legacy_transaction(transaction: TxLegacy) -> EvmTransaction { } fn seed_evm_account(fixture: &mut TestFixture, address: evm::Address, balance: U512) { + seed_evm_account_with_nonce(fixture, address, balance, 0); +} + +fn seed_evm_account_with_nonce( + fixture: &mut TestFixture, + address: evm::Address, + balance: U512, + nonce: u64, +) { let main_purse = evm::deterministic_purse(address); let values_to_write = vec![ ( @@ -1047,7 +1056,7 @@ fn seed_evm_account(fixture: &mut TestFixture, address: evm::Address, balance: U ), ( Key::Evm(EvmAddr::Nonce(address)), - StoredValue::CLValue(CLValue::from_t(0u64).unwrap()), + StoredValue::CLValue(CLValue::from_t(nonce).unwrap()), ), ( Key::Evm(EvmAddr::CodeHash(address)), @@ -1226,6 +1235,164 @@ fn alloy_address_to_evm_address(address: AlloyAddress) -> evm::Address { evm::Address::new(bytes) } +#[tokio::test] +async fn should_not_fatally_exit_for_competing_evm_transactions_with_the_same_nonce() { + const NONCE: u64 = 104; + + let evm_config = EvmConfig { + enabled: true, + chain_id: 0x4353_50FF, + spec: EvmSpec::Prague, + block_gas_limit: 30_000_000, + base_fee: 1, + wei_per_mote: DEFAULT_WEI_PER_MOTE, + }; + let config = SingleTransactionTestCase::default_test_config() + .with_evm_config(evm_config) + .with_refund_handling(RefundHandling::NoRefund) + .with_fee_handling(FeeHandling::Burn); + let mut test = SingleTransactionTestCase::new( + Arc::clone(&ALICE_SECRET_KEY), + Arc::clone(&BOB_SECRET_KEY), + Arc::clone(&CHARLIE_SECRET_KEY), + Some(config), + ) + .await; + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + let first = + signed_evm_create_transaction(evm_config.chain_id, NONCE, evm_log_emitting_init_code()); + let second = signed_evm_create_transaction( + evm_config.chain_id, + NONCE, + evm_init_code_returning(vec![opcode::STOP]), + ); + let sender = first.from(); + assert_eq!(second.from(), sender); + seed_evm_account_with_nonce( + &mut test.fixture, + sender, + U512::from(EVM_INITIAL_BALANCE), + NONCE, + ); + let initial_total_supply = test.get_total_supply(None); + let max_fee_amount = first + .max_fee_amount(&evm_config) + .expect("maximum EVM fee should fit"); + assert_eq!(second.max_fee_amount(&evm_config), Some(max_fee_amount)); + + let first_hash = Transaction::from(first.clone()).hash(); + let second_hash = Transaction::from(second.clone()).hash(); + assert_ne!(first_hash, second_hash); + + // Both transactions can be accepted against nonce 104 before either one executes. + test.fixture + .inject_transaction(Transaction::from(first)) + .await; + test.fixture + .inject_transaction(Transaction::from(second)) + .await; + + test.fixture + .run_until( + move |nodes| { + nodes.values().all(|runner| { + let storage = runner.main_reactor().storage(); + storage.read_execution_result(&first_hash).is_some() + && storage.read_execution_result(&second_hash).is_some() + }) + }, + Duration::from_secs(30), + ) + .await; + + let (first_execution_info, second_execution_info) = { + let (_node_id, runner) = test.fixture.network.nodes().iter().next().unwrap(); + let storage = runner.main_reactor().storage(); + ( + storage + .read_execution_info(first_hash) + .expect("first competing EVM transaction should be included"), + storage + .read_execution_info(second_hash) + .expect("second competing EVM transaction should be included"), + ) + }; + assert_eq!( + first_execution_info.block_height, + second_execution_info.block_height + ); + let block_height = first_execution_info.block_height; + test.fixture + .run_until( + move |nodes| { + nodes.values().all(|runner| { + runner + .main_reactor() + .storage() + .read_block_header_by_height(block_height, true) + .is_ok_and(|header| header.is_some()) + }) + }, + Duration::from_secs(30), + ) + .await; + let first_execution_result = first_execution_info + .execution_result + .expect("first competing EVM transaction should have an execution result"); + let second_execution_result = second_execution_info + .execution_result + .expect("second competing EVM transaction should have an execution result"); + + let (successful_result, invalid_nonce_result) = + match (first_execution_result, second_execution_result) { + (ExecutionResult::Evm(success), ExecutionResult::V2(invalid_nonce)) + | (ExecutionResult::V2(invalid_nonce), ExecutionResult::Evm(success)) => { + (success, invalid_nonce) + } + results => { + panic!("expected one successful and one invalid EVM transaction: {results:?}") + } + }; + + assert_eq!( + successful_result.receipt.status, + evm::ReceiptStatus::Success + ); + assert_eq!(successful_result.cost, max_fee_amount); + assert_eq!(successful_result.refund, U512::zero()); + + let expected_error = EvmTransactionError::InvalidNonce { + expected: NONCE + 1, + actual: NONCE, + } + .to_string(); + assert_eq!( + invalid_nonce_result.error_message.as_deref(), + Some(expected_error.as_str()) + ); + assert_eq!(invalid_nonce_result.cost, U512::zero()); + assert_eq!(invalid_nonce_result.consumed, Gas::zero()); + assert_eq!(invalid_nonce_result.refund, U512::zero()); + assert!(invalid_nonce_result.effects.is_empty()); + assert!(invalid_nonce_result.transfers.is_empty()); + + assert_eq!( + evm_account_at(&mut test.fixture, block_height, sender).nonce(), + NONCE + 1 + ); + assert_eq!( + evm_balance(&mut test.fixture, sender, block_height), + U512::from(EVM_INITIAL_BALANCE) - max_fee_amount + ); + assert_eq!( + test.get_total_supply(Some(block_height)), + initial_total_supply - max_fee_amount + ); +} + #[tokio::test] async fn should_execute_evm_transaction_and_store_receipt() { let evm_config = EvmConfig { From 49c5708bbc87fd20cbad1032d39c43dd0cc26588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:30:17 +0200 Subject: [PATCH 02/19] Reject stale EVM nonces before execution Compare each EVM nonce with the evolving block state before creating payment effects. Record a zero-cost transaction failure when an earlier transaction in the same block has already advanced that nonce. --- .../components/contract_runtime/operations.rs | 71 ++++++++++++++----- node/src/components/contract_runtime/types.rs | 7 ++ 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/node/src/components/contract_runtime/operations.rs b/node/src/components/contract_runtime/operations.rs index 491a53251c..c747110ae5 100644 --- a/node/src/components/contract_runtime/operations.rs +++ b/node/src/components/contract_runtime/operations.rs @@ -380,6 +380,16 @@ fn evm_account_has_nonce( tracking_copy: &mut TrackingCopy, address: EvmAddress, ) -> Result +where + R: StateReader, +{ + Ok(evm_account_nonce(tracking_copy, address)?.is_some()) +} + +fn evm_account_nonce( + tracking_copy: &mut TrackingCopy, + address: EvmAddress, +) -> Result, BlockExecutionError> where R: StateReader, { @@ -389,16 +399,16 @@ where .map_err(|error| BlockExecutionError::PaymentError(error.to_string()))? { Some(StoredValue::CLValue(cl_value)) => { - let _nonce = cl_value + let nonce = cl_value .into_t::() .map_err(|error| BlockExecutionError::PaymentError(error.to_string()))?; - Ok(true) + Ok(Some(nonce)) } Some(stored_value) => Err(BlockExecutionError::PaymentError(format!( "unexpected stored value for {key}: expected StoredValue::CLValue(u64), found {}", stored_value.type_name() ))), - None => Ok(false), + None => Ok(None), } } @@ -1018,7 +1028,7 @@ pub fn execute_finalized_block( )); artifact_builder.with_available(post_payment_balance_result.available_balance().copied()); - let allow_execution = { + let (allow_execution, is_valid_evm_nonce) = { let is_not_penalized = !balance_identifier.is_penalty(); // in the case of custom payment, we do all payment processing up front after checking // if the initiator can cover the penalty payment, and then either charge the full @@ -1041,7 +1051,33 @@ pub fn execute_finalized_block( let is_sufficient_balance = is_custom_payment || post_payment_balance_result.is_sufficient(required_balance); let is_allowed_by_chainspec = chainspec.is_supported(lane_id); - let allow = is_not_penalized && is_sufficient_balance && is_allowed_by_chainspec; + // Multiple EVM transactions with the same nonce can pass acceptance against the same + // pre-state and be included in one block. Since block execution is sequential, an + // earlier transaction can advance the account nonce before a later one reaches this + // precondition check. + let is_valid_evm_nonce = if let Some(evm_transaction) = evm_transaction { + let mut tracking_copy = scratch_state + .tracking_copy(state_root_hash)? + .ok_or(BlockExecutionError::RootNotFound(state_root_hash))?; + let expected = evm_account_nonce(&mut tracking_copy, evm_transaction.from())? + .unwrap_or_default(); + let actual = evm_transaction.nonce(); + if actual != expected { + let invalid_request = + casper_types::EvmTransactionError::InvalidNonce { expected, actual }; + debug!(%transaction_hash, %invalid_request, "invalid EVM request"); + artifact_builder.with_invalid_evm_request(&invalid_request); + false + } else { + true + } + } else { + true + }; + let allow = is_not_penalized + && is_sufficient_balance + && is_allowed_by_chainspec + && is_valid_evm_nonce; if !allow { let err_msg = { if !is_sufficient_balance { @@ -1056,11 +1092,11 @@ pub fn execute_finalized_block( if artifact_builder.error_message().is_none() { artifact_builder.with_error_message(err_msg); } - info!(%transaction_hash, ?balance_identifier, ?is_sufficient_balance, ?is_not_penalized, ?is_allowed_by_chainspec, "payment preprocessing unsuccessful"); + info!(%transaction_hash, ?balance_identifier, ?is_sufficient_balance, ?is_not_penalized, ?is_allowed_by_chainspec, ?is_valid_evm_nonce, "payment preprocessing unsuccessful"); } else { - debug!(%transaction_hash, ?balance_identifier, ?is_sufficient_balance, ?is_not_penalized, ?is_allowed_by_chainspec, "payment preprocessing successful"); + debug!(%transaction_hash, ?balance_identifier, ?is_sufficient_balance, ?is_not_penalized, ?is_allowed_by_chainspec, ?is_valid_evm_nonce, "payment preprocessing successful"); } - allow + (allow, is_valid_evm_nonce) }; if allow_execution { @@ -1321,14 +1357,17 @@ pub fn execute_finalized_block( } if is_evm && !allow_execution { - let effective_gas_price = evm_transaction - .expect("EVM transaction should exist") - .effective_gas_price(chainspec.evm_config.base_fee_wei()); - artifact_builder.with_zero_cost().with_evm_receipt( - evm_precondition_receipt(effective_gas_price), - U512::zero(), - Effects::new(), - ); + artifact_builder.with_zero_cost(); + if is_valid_evm_nonce { + let effective_gas_price = evm_transaction + .expect("EVM transaction should exist") + .effective_gas_price(chainspec.evm_config.base_fee_wei()); + artifact_builder.with_evm_receipt( + evm_precondition_receipt(effective_gas_price), + U512::zero(), + Effects::new(), + ); + } artifacts.push(artifact_builder.build()); continue; } diff --git a/node/src/components/contract_runtime/types.rs b/node/src/components/contract_runtime/types.rs index b53d0c9d40..a5ef2827d7 100644 --- a/node/src/components/contract_runtime/types.rs +++ b/node/src/components/contract_runtime/types.rs @@ -413,6 +413,13 @@ impl ExecutionArtifactBuilder { self } + pub fn with_invalid_evm_request(&mut self, invalid_request: &EvmTransactionError) -> &mut Self { + if self.error_message.is_none() { + self.error_message = Some(format!("{}", invalid_request)); + } + self + } + pub fn with_auction_method_error( &mut self, auction_method_error: &AuctionMethodError, From 066f8d3793de0534cddb99caa16cc7e48d2e3b33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:30:43 +0200 Subject: [PATCH 03/19] Test EVM validation failures during execution Cover intrinsic gas, Prague calldata floors, oversized initcode, nonce overflow, and senders with deployed code. Each case must produce a zero-cost transaction failure without stopping the reactor or changing state. --- .../main_reactor/tests/transactions.rs | 200 +++++++++++++++++- 1 file changed, 195 insertions(+), 5 deletions(-) diff --git a/node/src/reactor/main_reactor/tests/transactions.rs b/node/src/reactor/main_reactor/tests/transactions.rs index 41b1640001..41580e5d6e 100644 --- a/node/src/reactor/main_reactor/tests/transactions.rs +++ b/node/src/reactor/main_reactor/tests/transactions.rs @@ -7,7 +7,8 @@ use crate::{ use alloy_consensus::{SignableTransaction, TxEip1559, TxEnvelope, TxLegacy}; use alloy_eips::Encodable2718; use alloy_primitives::{ - Address as AlloyAddress, Bytes as AlloyBytes, Signature as AlloySignature, TxKind, U256, + keccak256, Address as AlloyAddress, Bytes as AlloyBytes, Signature as AlloySignature, TxKind, + U256, }; use casper_executor_evm::EMPTY_CODE_HASH; use casper_storage::{ @@ -22,8 +23,9 @@ use casper_types::{ addressable_entity::NamedKeyAddr, runtime_args, system::mint::{ARG_AMOUNT, ARG_TARGET}, - AccessRights, AddressableEntity, CLValue, Digest, EntityAddr, ExecutableDeployItem, - ExecutionInfo, InitiatorAddr, TransactionRuntimeParams, URef, URefAddr, DEFAULT_TRANSFER_COST, + AccessRights, AddressableEntity, ByteCode, ByteCodeKind, CLValue, Digest, EntityAddr, + ExecutableDeployItem, ExecutionInfo, InitiatorAddr, TransactionRuntimeParams, URef, URefAddr, + DEFAULT_TRANSFER_COST, }; use k256::ecdsa::{signature::hazmat::PrehashSigner, SigningKey}; use once_cell::sync::Lazy; @@ -1047,9 +1049,27 @@ fn seed_evm_account_with_nonce( address: evm::Address, balance: U512, nonce: u64, +) { + seed_evm_account_with_nonce_and_code(fixture, address, balance, nonce, None); +} + +fn seed_evm_account_with_nonce_and_code( + fixture: &mut TestFixture, + address: evm::Address, + balance: U512, + nonce: u64, + code: Option>, ) { let main_purse = evm::deterministic_purse(address); - let values_to_write = vec![ + let code_hash = code + .as_ref() + .map(|code| { + let mut bytes = [0; evm::HASH_LENGTH]; + bytes.copy_from_slice(keccak256(code).as_slice()); + evm::Hash::new(bytes) + }) + .unwrap_or(EMPTY_CODE_HASH); + let mut values_to_write = vec![ ( Key::Evm(EvmAddr::Account(address)), StoredValue::CLValue(CLValue::from_t(Key::URef(main_purse)).unwrap()), @@ -1060,13 +1080,19 @@ fn seed_evm_account_with_nonce( ), ( Key::Evm(EvmAddr::CodeHash(address)), - StoredValue::CLValue(CLValue::from_t(EMPTY_CODE_HASH).unwrap()), + StoredValue::CLValue(CLValue::from_t(code_hash).unwrap()), ), ( Key::Balance(main_purse.addr()), StoredValue::CLValue(CLValue::from_t(balance).unwrap()), ), ]; + if let Some(code) = code { + values_to_write.push(( + Key::Evm(EvmAddr::ByteCode(code_hash)), + StoredValue::ByteCode(ByteCode::new(ByteCodeKind::EvmPrague, code)), + )); + } for runner in fixture.network.runners_mut() { let execution_pre_state = runner .main_reactor() @@ -1235,6 +1261,170 @@ fn alloy_address_to_evm_address(address: AlloyAddress) -> evm::Address { evm::Address::new(bytes) } +async fn assert_evm_transaction_validation_failure_is_not_fatal( + transaction: TxLegacy, + account_nonce: u64, + sender_code: Option>, + expected_error_fragment: &str, +) { + let evm_config = EvmConfig { + enabled: true, + chain_id: transaction + .chain_id + .expect("test transaction should have a chain ID"), + spec: EvmSpec::Prague, + block_gas_limit: 30_000_000, + base_fee: 1, + wei_per_mote: DEFAULT_WEI_PER_MOTE, + }; + let config = SingleTransactionTestCase::default_test_config() + .with_evm_config(evm_config) + .with_refund_handling(RefundHandling::NoRefund) + .with_fee_handling(FeeHandling::Burn); + let mut test = SingleTransactionTestCase::new( + Arc::clone(&ALICE_SECRET_KEY), + Arc::clone(&BOB_SECRET_KEY), + Arc::clone(&CHARLIE_SECRET_KEY), + Some(config), + ) + .await; + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + let evm_transaction = signed_evm_legacy_transaction(transaction); + let sender = evm_transaction.from(); + seed_evm_account_with_nonce_and_code( + &mut test.fixture, + sender, + U512::from(EVM_INITIAL_BALANCE), + account_nonce, + sender_code, + ); + let initial_total_supply = test.get_total_supply(None); + + let (_transaction_hash, block_height, execution_result) = test + .send_transaction(Transaction::from(evm_transaction)) + .await; + let ExecutionResult::V2(failure) = execution_result else { + panic!("expected EVM transaction validation to produce a V2 precondition failure"); + }; + let error_message = failure + .error_message + .as_deref() + .expect("precondition failure should include an error message"); + assert!( + error_message.contains(expected_error_fragment), + "expected error containing {expected_error_fragment:?}, got {error_message:?}" + ); + assert_eq!(failure.cost, U512::zero()); + assert_eq!(failure.consumed, Gas::zero()); + assert_eq!(failure.refund, U512::zero()); + assert!(failure.effects.is_empty()); + assert!(failure.transfers.is_empty()); + assert_eq!( + evm_account_at(&mut test.fixture, block_height, sender).nonce(), + account_nonce + ); + assert_eq!( + evm_balance(&mut test.fixture, sender, block_height), + U512::from(EVM_INITIAL_BALANCE) + ); + assert_eq!( + test.get_total_supply(Some(block_height)), + initial_total_supply + ); +} + +#[tokio::test] +async fn should_not_fatally_exit_for_evm_transaction_below_intrinsic_gas() { + let transaction = TxLegacy { + chain_id: Some(0x4353_50FF), + nonce: 0, + gas_price: EVM_TEST_GAS_PRICE, + gas_limit: 20_999, + to: TxKind::Call(AlloyAddress::repeat_byte(0x22)), + value: U256::ZERO, + input: AlloyBytes::new(), + }; + assert_evm_transaction_validation_failure_is_not_fatal(transaction, 0, None, "call gas cost") + .await; +} + +#[tokio::test] +async fn should_not_fatally_exit_for_evm_transaction_below_prague_calldata_floor() { + let transaction = TxLegacy { + chain_id: Some(0x4353_50FF), + nonce: 0, + gas_price: EVM_TEST_GAS_PRICE, + gas_limit: 23_000, + to: TxKind::Call(AlloyAddress::repeat_byte(0x22)), + value: U256::ZERO, + input: AlloyBytes::from(vec![0xFF; 100]), + }; + assert_evm_transaction_validation_failure_is_not_fatal(transaction, 0, None, "gas floor").await; +} + +#[tokio::test] +async fn should_not_fatally_exit_for_evm_transaction_with_oversized_initcode() { + let transaction = TxLegacy { + chain_id: Some(0x4353_50FF), + nonce: 0, + gas_price: EVM_TEST_GAS_PRICE, + gas_limit: EVM_TEST_GAS_LIMIT, + to: TxKind::Create, + value: U256::ZERO, + input: AlloyBytes::from(vec![0; 49_153]), + }; + assert_evm_transaction_validation_failure_is_not_fatal( + transaction, + 0, + None, + "create initcode size limit", + ) + .await; +} + +#[tokio::test] +async fn should_not_fatally_exit_for_evm_transaction_with_nonce_overflow() { + let transaction = TxLegacy { + chain_id: Some(0x4353_50FF), + nonce: u64::MAX, + gas_price: EVM_TEST_GAS_PRICE, + gas_limit: EVM_TEST_GAS_LIMIT, + to: TxKind::Call(AlloyAddress::repeat_byte(0x22)), + value: U256::ZERO, + input: AlloyBytes::new(), + }; + assert_evm_transaction_validation_failure_is_not_fatal( + transaction, + u64::MAX, + None, + "nonce overflow in transaction", + ) + .await; +} + +#[tokio::test] +async fn should_not_fatally_exit_for_evm_transaction_from_sender_with_code() { + let transaction = TxLegacy { + chain_id: Some(0x4353_50FF), + nonce: 0, + gas_price: EVM_TEST_GAS_PRICE, + gas_limit: EVM_TEST_GAS_LIMIT, + to: TxKind::Call(AlloyAddress::repeat_byte(0x22)), + value: U256::ZERO, + input: AlloyBytes::new(), + }; + assert_evm_transaction_validation_failure_is_not_fatal( + transaction, + 0, + Some(vec![opcode::STOP]), + "reject transactions from senders with deployed code", + ) + .await; +} + #[tokio::test] async fn should_not_fatally_exit_for_competing_evm_transactions_with_the_same_nonce() { const NONCE: u64 = 104; From d9d9a0d61230eec4d55830aaa3d6286fe42e1338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:30:54 +0200 Subject: [PATCH 04/19] Validate EVM preconditions before payment Run revm transaction validation against the evolving block state before creating payment effects. Convert every current validation rejection into a per-transaction error so malformed execution requests cannot stop the reactor. --- executor/evm/src/error.rs | 5 +- executor/evm/src/executor.rs | 82 ++++++++++++- .../components/contract_runtime/operations.rs | 110 +++++++++++++----- types/src/evm/transaction.rs | 5 + 4 files changed, 170 insertions(+), 32 deletions(-) diff --git a/executor/evm/src/error.rs b/executor/evm/src/error.rs index 06cc564ce7..23095373dc 100644 --- a/executor/evm/src/error.rs +++ b/executor/evm/src/error.rs @@ -1,7 +1,7 @@ //! Error types returned by the Casper EVM executor. use casper_storage::{block_store::BlockStoreError, tracking_copy::TrackingCopyError}; -use casper_types::Key; +use casper_types::{EvmTransactionError, Key}; use crate::account_state::AccountStorageError; @@ -34,6 +34,9 @@ pub enum Error { /// Failed to translate revm transaction environment. #[error("failed to build EVM transaction environment: {0}")] Transaction(String), + /// revm rejected the transaction during pre-execution validation. + #[error("EVM transaction validation failed: {0}")] + InvalidTransaction(#[source] EvmTransactionError), /// revm rejected execution before producing state. #[error("EVM execution failed: {0}")] Revm(String), diff --git a/executor/evm/src/executor.rs b/executor/evm/src/executor.rs index 863fbfbe72..2829678962 100644 --- a/executor/evm/src/executor.rs +++ b/executor/evm/src/executor.rs @@ -5,17 +5,21 @@ use casper_storage::{ global_state::{error::Error as GlobalStateError, state::StateReader}, TrackingCopy, }; -use casper_types::{EvmConfig, EvmSpec, Key, StoredValue}; +use casper_types::{EvmConfig, EvmSpec, EvmTransaction, EvmTransactionError, Key, StoredValue}; use revm::{ context::CfgEnv, - context_interface::result::{EVMError, ExecutionResult as RevmExecutionResult, ResultGas}, + context_interface::result::{ + EVMError, ExecutionResult as RevmExecutionResult, + InvalidTransaction as RevmInvalidTransaction, ResultGas, + }, + handler::{Handler, MainnetHandler}, primitives::{hardfork::SpecId, Bytes, U256}, Context, ExecuteEvm, MainBuilder, MainContext, SystemCallEvm, }; use crate::{ - db::CasperDb, precompiles::CasperEvmPrecompiles, state, tx, DbError, Error, ExecuteKind, - ExecuteRequest, ExecutionOutcome, Result, SystemCallRequest, + db::CasperDb, precompiles::CasperEvmPrecompiles, state, tx, BlockContext, DbError, Error, + ExecuteKind, ExecuteRequest, ExecutionOutcome, Result, SystemCallRequest, }; /// Executes EVM transactions and calls against a Casper tracking copy. @@ -46,6 +50,60 @@ impl EvmExecutor { &self.config } + /// Validates an EVM transaction without executing it or applying state changes. + pub fn validate_transaction( + &self, + data_access_layer: &DataAccessLayer, + tracking_copy: &mut TrackingCopy, + block_context: BlockContext, + transaction: &EvmTransaction, + ) -> Result<()> + where + R: StateReader, + { + if !self.config.enabled { + return Err(Error::Disabled); + } + if self.config.wei_per_mote == 0 { + return Err(Error::InvalidWeiPerMote); + } + + let Some(actual) = transaction.chain_id() else { + return Err(Error::MissingChainId); + }; + if actual != self.config.chain_id { + return Err(Error::ChainIdMismatch { + expected: self.config.chain_id, + actual, + }); + } + + let kind = ExecuteKind::Transaction(Box::new(transaction.clone())); + let tx_env = tx::build_tx_env(&self.config, &kind)?; + let block = block_context.to_revm_block(&self.config)?; + let db = CasperDb::new(data_access_layer, tracking_copy, self.config.wei_per_mote); + let mut evm = Context::mainnet() + .with_db(db) + .with_block(block) + .with_tx(tx_env) + .modify_cfg_chained(|cfg| { + configure_evm_cfg(cfg, &self.config, EvmExecutionMode::Checked); + }) + .build_mainnet() + .with_precompiles(CasperEvmPrecompiles::new(spec_id(self.config.spec))); + + // Use revm's own validation phases so this stays in sync as Ethereum + // transaction preconditions grow. Journal mutations made while loading + // and checking the caller are discarded with this temporary EVM. + let handler = MainnetHandler::default(); + let mut initial_gas = handler + .validate(&mut evm) + .map_err(map_revm_validation_error)?; + handler + .validate_against_state_and_deduct_caller(&mut evm, &mut initial_gas) + .map_err(map_revm_validation_error) + } + /// Executes an EVM transaction or call against the supplied tracking copy. pub fn execute( &self, @@ -234,3 +292,19 @@ fn map_revm_error(error: EVMError) -> Error { other => Error::Revm(other.to_string()), } } + +fn map_revm_validation_error(error: EVMError) -> Error { + match error { + EVMError::Transaction( + RevmInvalidTransaction::NonceTooHigh { tx, state } + | RevmInvalidTransaction::NonceTooLow { tx, state }, + ) => Error::InvalidTransaction(EvmTransactionError::InvalidNonce { + expected: state, + actual: tx, + }), + EVMError::Transaction(error) => { + Error::InvalidTransaction(EvmTransactionError::Validation(error.to_string())) + } + other => map_revm_error(other), + } +} diff --git a/node/src/components/contract_runtime/operations.rs b/node/src/components/contract_runtime/operations.rs index c747110ae5..e736967b4c 100644 --- a/node/src/components/contract_runtime/operations.rs +++ b/node/src/components/contract_runtime/operations.rs @@ -11,8 +11,9 @@ use casper_execution_engine::engine_state::{ }; use casper_executor_evm::{ BlockContext as EvmBlockContext, CallRequest as EvmExecutorCallRequest, - CallValidation as EvmCallValidation, EvmExecutor, ExecuteKind as EvmExecuteKind, - ExecuteRequest as EvmExecuteRequest, ExecutionStatus as EvmExecutionStatus, + CallValidation as EvmCallValidation, Error as EvmExecutorError, EvmExecutor, + ExecuteKind as EvmExecuteKind, ExecuteRequest as EvmExecuteRequest, + ExecutionStatus as EvmExecutionStatus, }; use casper_storage::{ block_store::{lmdb::LmdbBlockStore, types::ApprovalsHashes}, @@ -47,9 +48,9 @@ use casper_types::{ execution::{Effects, ExecutionResult, TransformKindV2, TransformV2}, system::handle_payment::ARG_AMOUNT, BlockHash, BlockHeader, BlockTime, BlockV2, CLValue, Chainspec, ChecksumRegistry, Digest, - EntityAddr, EraEndV2, EraId, FeeHandling, Gas, InvalidTransaction, InvalidTransactionV1, Key, - ProtocolVersion, PublicKey, RefundHandling, StoredValue, TimeDiff, Transaction, - TransactionEntryPoint, AUCTION_LANE_ID, MINT_LANE_ID, U512, + EntityAddr, EraEndV2, EraId, EvmTransactionError as CasperEvmTransactionError, FeeHandling, + Gas, InvalidTransaction, InvalidTransactionV1, Key, ProtocolVersion, PublicKey, RefundHandling, + StoredValue, TimeDiff, Transaction, TransactionEntryPoint, AUCTION_LANE_ID, MINT_LANE_ID, U512, }; use super::{ @@ -143,6 +144,47 @@ fn evm_consumed_gas(status: EvmExecutionStatus, gas_used: u64, gas_limit: u64) - } } +fn evm_transaction_precondition_failure( + data_access_layer: &DataAccessLayer, + tracking_copy: &mut TrackingCopy, + protocol_version: ProtocolVersion, + evm_config: casper_types::EvmConfig, + block_context: EvmBlockContext, + transaction: &casper_types::EvmTransaction, + identity_plan: EvmIdentityPlan, +) -> Result, BlockExecutionError> +where + R: StateReader, +{ + // Execution applies this plan immediately before entering the EVM. Apply it + // only to this disposable tracking copy so validation sees the same caller + // identity without committing identity state for a rejected transaction. + apply_evm_identity_plan(tracking_copy, protocol_version, identity_plan)?; + + let result = EvmExecutor::new(evm_config).validate_transaction( + data_access_layer, + tracking_copy, + block_context, + transaction, + ); + let invalid_request = match result { + Ok(()) => return Ok(None), + Err(EvmExecutorError::Disabled) => CasperEvmTransactionError::Disabled, + Err(EvmExecutorError::MissingChainId) => CasperEvmTransactionError::MissingChainId, + Err(EvmExecutorError::ChainIdMismatch { expected, actual }) => { + CasperEvmTransactionError::ChainIdMismatch { expected, actual } + } + Err(EvmExecutorError::InvalidTransaction(error)) => error, + Err(EvmExecutorError::Transaction(error)) => CasperEvmTransactionError::Validation(error), + Err(error) => { + return Err(BlockExecutionError::TransactionConversion( + error.to_string(), + )) + } + }; + Ok(Some(invalid_request)) +} + #[derive(Clone, Debug)] struct EvmOriginResolution { // Concrete payer selected before payment checks. This is deliberately a @@ -1028,7 +1070,8 @@ pub fn execute_finalized_block( )); artifact_builder.with_available(post_payment_balance_result.available_balance().copied()); - let (allow_execution, is_valid_evm_nonce) = { + let is_valid_evm_request; + let allow_execution = { let is_not_penalized = !balance_identifier.is_penalty(); // in the case of custom payment, we do all payment processing up front after checking // if the initiator can cover the penalty payment, and then either charge the full @@ -1051,25 +1094,38 @@ pub fn execute_finalized_block( let is_sufficient_balance = is_custom_payment || post_payment_balance_result.is_sufficient(required_balance); let is_allowed_by_chainspec = chainspec.is_supported(lane_id); - // Multiple EVM transactions with the same nonce can pass acceptance against the same - // pre-state and be included in one block. Since block execution is sequential, an - // earlier transaction can advance the account nonce before a later one reaches this - // precondition check. - let is_valid_evm_nonce = if let Some(evm_transaction) = evm_transaction { + // Transactions are accepted against a shared pre-state, but execute sequentially + // against the evolving block state. Run all revm transaction preconditions here so + // any state-dependent mismatch becomes a per-transaction failure before a payment + // hold or other durable effect is created. + is_valid_evm_request = if let (Some(evm_transaction), Some(origin_resolution)) = + (evm_transaction, evm_origin_resolution.as_ref()) + { + let block_context = evm_block_context( + chainspec, + block_height, + block_time, + &proposer, + evm_prevrandao(parent_seed), + ); let mut tracking_copy = scratch_state .tracking_copy(state_root_hash)? .ok_or(BlockExecutionError::RootNotFound(state_root_hash))?; - let expected = evm_account_nonce(&mut tracking_copy, evm_transaction.from())? - .unwrap_or_default(); - let actual = evm_transaction.nonce(); - if actual != expected { - let invalid_request = - casper_types::EvmTransactionError::InvalidNonce { expected, actual }; - debug!(%transaction_hash, %invalid_request, "invalid EVM request"); - artifact_builder.with_invalid_evm_request(&invalid_request); - false - } else { - true + match evm_transaction_precondition_failure( + data_access_layer, + &mut tracking_copy, + protocol_version, + chainspec.evm_config, + block_context, + evm_transaction, + origin_resolution.identity_plan, + )? { + Some(invalid_request) => { + debug!(%transaction_hash, %invalid_request, "invalid EVM request"); + artifact_builder.with_invalid_evm_request(&invalid_request); + false + } + None => true, } } else { true @@ -1077,7 +1133,7 @@ pub fn execute_finalized_block( let allow = is_not_penalized && is_sufficient_balance && is_allowed_by_chainspec - && is_valid_evm_nonce; + && is_valid_evm_request; if !allow { let err_msg = { if !is_sufficient_balance { @@ -1092,11 +1148,11 @@ pub fn execute_finalized_block( if artifact_builder.error_message().is_none() { artifact_builder.with_error_message(err_msg); } - info!(%transaction_hash, ?balance_identifier, ?is_sufficient_balance, ?is_not_penalized, ?is_allowed_by_chainspec, ?is_valid_evm_nonce, "payment preprocessing unsuccessful"); + info!(%transaction_hash, ?balance_identifier, ?is_sufficient_balance, ?is_not_penalized, ?is_allowed_by_chainspec, ?is_valid_evm_request, "payment preprocessing unsuccessful"); } else { - debug!(%transaction_hash, ?balance_identifier, ?is_sufficient_balance, ?is_not_penalized, ?is_allowed_by_chainspec, ?is_valid_evm_nonce, "payment preprocessing successful"); + debug!(%transaction_hash, ?balance_identifier, ?is_sufficient_balance, ?is_not_penalized, ?is_allowed_by_chainspec, ?is_valid_evm_request, "payment preprocessing successful"); } - (allow, is_valid_evm_nonce) + allow }; if allow_execution { @@ -1358,7 +1414,7 @@ pub fn execute_finalized_block( if is_evm && !allow_execution { artifact_builder.with_zero_cost(); - if is_valid_evm_nonce { + if is_valid_evm_request { let effective_gas_price = evm_transaction .expect("EVM transaction should exist") .effective_gas_price(chainspec.evm_config.base_fee_wei()); diff --git a/types/src/evm/transaction.rs b/types/src/evm/transaction.rs index 0df1129623..d7247c6879 100644 --- a/types/src/evm/transaction.rs +++ b/types/src/evm/transaction.rs @@ -501,6 +501,8 @@ pub enum EvmTransactionError { /// EvmTransaction nonce. actual: u64, }, + /// The execution engine rejected an EVM transaction precondition. + Validation(String), /// The transaction does not contain an EVM approval. MissingApproval, /// The approval is not a secp256k1 signature and public key. @@ -622,6 +624,9 @@ impl Display for EvmTransactionError { "EVM transaction nonce {actual} does not match account nonce {expected}" ) } + EvmTransactionError::Validation(error) => { + write!(formatter, "EVM transaction validation error: {error}") + } EvmTransactionError::MissingApproval => formatter.write_str("missing EVM approval"), EvmTransactionError::NonSecp256k1Approval => { formatter.write_str("EVM approval must use secp256k1") From de28ea0065f3dfd2ca838239686ae58daf9044fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:31:23 +0200 Subject: [PATCH 05/19] Test EVM result variants for rejected requests Require block-included EVM transactions to retain the EVM execution result shape when a precondition rejects them. Sidecar relies on that variant to project receipts for every EVM transaction in a block. --- .../main_reactor/tests/transactions.rs | 75 ++++++++++++------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/node/src/reactor/main_reactor/tests/transactions.rs b/node/src/reactor/main_reactor/tests/transactions.rs index 41580e5d6e..4fcd284e04 100644 --- a/node/src/reactor/main_reactor/tests/transactions.rs +++ b/node/src/reactor/main_reactor/tests/transactions.rs @@ -1306,22 +1306,27 @@ async fn assert_evm_transaction_validation_failure_is_not_fatal( let (_transaction_hash, block_height, execution_result) = test .send_transaction(Transaction::from(evm_transaction)) .await; - let ExecutionResult::V2(failure) = execution_result else { - panic!("expected EVM transaction validation to produce a V2 precondition failure"); - }; - let error_message = failure - .error_message - .as_deref() + let error_message = execution_result + .error_message() .expect("precondition failure should include an error message"); assert!( error_message.contains(expected_error_fragment), "expected error containing {expected_error_fragment:?}, got {error_message:?}" ); + // Sidecar projects every block-included EVM transaction from an EVM result, including + // transactions rejected by an execution-time precondition. + let ExecutionResult::Evm(failure) = execution_result else { + panic!("expected EVM transaction validation to produce an EVM execution result"); + }; + assert!( + !failure.receipt.status.is_success(), + "EVM validation failure should have a failed receipt" + ); assert_eq!(failure.cost, U512::zero()); - assert_eq!(failure.consumed, Gas::zero()); + assert_eq!(failure.receipt.gas_used, 0); assert_eq!(failure.refund, U512::zero()); assert!(failure.effects.is_empty()); - assert!(failure.transfers.is_empty()); + assert!(failure.receipt.logs.is_empty()); assert_eq!( evm_account_at(&mut test.fixture, block_height, sender).nonce(), account_nonce @@ -1536,16 +1541,36 @@ async fn should_not_fatally_exit_for_competing_evm_transactions_with_the_same_no .execution_result .expect("second competing EVM transaction should have an execution result"); - let (successful_result, invalid_nonce_result) = - match (first_execution_result, second_execution_result) { - (ExecutionResult::Evm(success), ExecutionResult::V2(invalid_nonce)) - | (ExecutionResult::V2(invalid_nonce), ExecutionResult::Evm(success)) => { - (success, invalid_nonce) - } - results => { - panic!("expected one successful and one invalid EVM transaction: {results:?}") - } - }; + let expected_error = EvmTransactionError::InvalidNonce { + expected: NONCE + 1, + actual: NONCE, + } + .to_string(); + let first_error = first_execution_result.error_message(); + let second_error = second_execution_result.error_message(); + assert!( + matches!( + (first_error.as_deref(), second_error.as_deref()), + (Some(actual), None) | (None, Some(actual)) if actual == expected_error + ), + "expected one successful result and one {expected_error:?} failure, got \ + {first_error:?} and {second_error:?}" + ); + + // A failed precondition must not change the result variant: otherwise one rejected EVM + // transaction prevents sidecar from projecting receipts for every EVM transaction in the block. + let (first_result, second_result) = match (first_execution_result, second_execution_result) { + (ExecutionResult::Evm(first), ExecutionResult::Evm(second)) => (first, second), + results => panic!("expected both EVM transactions to have EVM results: {results:?}"), + }; + let (successful_result, invalid_nonce_result) = match ( + first_result.receipt.status.is_success(), + second_result.receipt.status.is_success(), + ) { + (true, false) => (first_result, second_result), + (false, true) => (second_result, first_result), + statuses => panic!("expected one successful and one failed EVM receipt: {statuses:?}"), + }; assert_eq!( successful_result.receipt.status, @@ -1554,20 +1579,12 @@ async fn should_not_fatally_exit_for_competing_evm_transactions_with_the_same_no assert_eq!(successful_result.cost, max_fee_amount); assert_eq!(successful_result.refund, U512::zero()); - let expected_error = EvmTransactionError::InvalidNonce { - expected: NONCE + 1, - actual: NONCE, - } - .to_string(); - assert_eq!( - invalid_nonce_result.error_message.as_deref(), - Some(expected_error.as_str()) - ); + assert!(!invalid_nonce_result.receipt.status.is_success()); assert_eq!(invalid_nonce_result.cost, U512::zero()); - assert_eq!(invalid_nonce_result.consumed, Gas::zero()); + assert_eq!(invalid_nonce_result.receipt.gas_used, 0); assert_eq!(invalid_nonce_result.refund, U512::zero()); assert!(invalid_nonce_result.effects.is_empty()); - assert!(invalid_nonce_result.transfers.is_empty()); + assert!(invalid_nonce_result.receipt.logs.is_empty()); assert_eq!( evm_account_at(&mut test.fixture, block_height, sender).nonce(), From 8e62df4be546f20375cb62c482e367800719701b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:31:49 +0200 Subject: [PATCH 06/19] Preserve EVM results for rejected requests Build an EVM receipt for every rejected block-included EVM request and carry the precondition error alongside it. This keeps receipt projection consistent while retaining a useful error message for clients. --- .../components/contract_runtime/operations.rs | 19 ++++++++----------- node/src/components/contract_runtime/types.rs | 1 + .../main_reactor/tests/transactions.rs | 12 ++++++++++++ types/src/execution/evm_execution_result.rs | 12 ++++++++++-- types/src/execution/execution_result.rs | 5 ++++- 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/node/src/components/contract_runtime/operations.rs b/node/src/components/contract_runtime/operations.rs index e736967b4c..8007dda3ba 100644 --- a/node/src/components/contract_runtime/operations.rs +++ b/node/src/components/contract_runtime/operations.rs @@ -1413,17 +1413,14 @@ pub fn execute_finalized_block( } if is_evm && !allow_execution { - artifact_builder.with_zero_cost(); - if is_valid_evm_request { - let effective_gas_price = evm_transaction - .expect("EVM transaction should exist") - .effective_gas_price(chainspec.evm_config.base_fee_wei()); - artifact_builder.with_evm_receipt( - evm_precondition_receipt(effective_gas_price), - U512::zero(), - Effects::new(), - ); - } + let effective_gas_price = evm_transaction + .expect("EVM transaction should exist") + .effective_gas_price(chainspec.evm_config.base_fee_wei()); + artifact_builder.with_zero_cost().with_evm_receipt( + evm_precondition_receipt(effective_gas_price), + U512::zero(), + Effects::new(), + ); artifacts.push(artifact_builder.build()); continue; } diff --git a/node/src/components/contract_runtime/types.rs b/node/src/components/contract_runtime/types.rs index a5ef2827d7..19387084d9 100644 --- a/node/src/components/contract_runtime/types.rs +++ b/node/src/components/contract_runtime/types.rs @@ -501,6 +501,7 @@ impl ExecutionArtifactBuilder { .initiator .evm_address() .expect("EVM execution result requires an EVM initiator"), + error_message: self.error_message, current_price: self.current_price, limit: self.limit, cost: actual_cost, diff --git a/node/src/reactor/main_reactor/tests/transactions.rs b/node/src/reactor/main_reactor/tests/transactions.rs index 4fcd284e04..721f2b2bce 100644 --- a/node/src/reactor/main_reactor/tests/transactions.rs +++ b/node/src/reactor/main_reactor/tests/transactions.rs @@ -1961,6 +1961,14 @@ async fn should_require_balance_for_eip1559_signed_maximum() { execution_result.receipt.status, evm::ReceiptStatus::Halt(evm::HaltReason::Unknown) ); + assert!( + execution_result + .error_message + .as_deref() + .is_some_and(|message| message.contains("has less than")), + "expected insufficient purse balance error, got {:?}", + execution_result.error_message + ); assert_eq!(execution_result.receipt.gas_used, 0); assert_eq!(execution_result.cost, U512::zero()); assert_eq!(execution_result.refund, U512::zero()); @@ -2124,6 +2132,10 @@ async fn should_reject_evm_transaction_when_value_and_fee_exceed_balance() { execution_result.receipt.status, evm::ReceiptStatus::Halt(evm::HaltReason::Unknown) ); + assert_eq!( + execution_result.error_message.as_deref(), + Some("Insufficient funds") + ); assert_eq!(execution_result.receipt.gas_used, 0); assert_eq!(execution_result.cost, U512::zero()); assert_eq!(execution_result.refund, U512::zero()); diff --git a/types/src/execution/evm_execution_result.rs b/types/src/execution/evm_execution_result.rs index 9b980de710..aa23948ada 100644 --- a/types/src/execution/evm_execution_result.rs +++ b/types/src/execution/evm_execution_result.rs @@ -1,6 +1,6 @@ //! EVM transaction execution result types. -use alloc::vec::Vec; +use alloc::{string::String, vec::Vec}; #[cfg(feature = "datasize")] use datasize::DataSize; @@ -26,6 +26,8 @@ use crate::{ pub struct EvmExecutionResult { /// Who initiated this EVM transaction. pub initiator: evm::Address, + /// If present, the transaction failed to fully process for the stated reason. + pub error_message: Option, /// The current Casper gas price used for fee accounting. pub current_price: u8, /// The maximum allowed gas limit for this transaction. @@ -49,15 +51,17 @@ impl EvmExecutionResult { let limit = Gas::new(rng.gen::()); let gas_price = rng.gen_range(1..6); let cost = limit.value() * U512::from(gas_price); + let receipt = evm::Receipt::random(rng); EvmExecutionResult { initiator: evm::Address::new(rng.gen()), + error_message: receipt.status.message().map(String::from), current_price: gas_price, limit, cost, refund: rng.gen::().into(), size_estimate: rng.gen(), effects: Effects::random(rng), - receipt: evm::Receipt::random(rng), + receipt, } } } @@ -71,6 +75,7 @@ impl ToBytes for EvmExecutionResult { fn serialized_length(&self) -> usize { self.initiator.serialized_length() + + self.error_message.serialized_length() + self.current_price.serialized_length() + self.limit.serialized_length() + self.cost.serialized_length() @@ -82,6 +87,7 @@ impl ToBytes for EvmExecutionResult { fn write_bytes(&self, writer: &mut Vec) -> Result<(), bytesrepr::Error> { self.initiator.write_bytes(writer)?; + self.error_message.write_bytes(writer)?; self.current_price.write_bytes(writer)?; self.limit.write_bytes(writer)?; self.cost.write_bytes(writer)?; @@ -95,6 +101,7 @@ impl ToBytes for EvmExecutionResult { impl FromBytes for EvmExecutionResult { fn from_bytes(bytes: &[u8]) -> Result<(Self, &[u8]), bytesrepr::Error> { let (initiator, remainder) = evm::Address::from_bytes(bytes)?; + let (error_message, remainder) = Option::::from_bytes(remainder)?; let (current_price, remainder) = u8::from_bytes(remainder)?; let (limit, remainder) = Gas::from_bytes(remainder)?; let (cost, remainder) = U512::from_bytes(remainder)?; @@ -105,6 +112,7 @@ impl FromBytes for EvmExecutionResult { Ok(( EvmExecutionResult { initiator, + error_message, current_price, limit, cost, diff --git a/types/src/execution/execution_result.rs b/types/src/execution/execution_result.rs index f84435c242..e190f84e16 100644 --- a/types/src/execution/execution_result.rs +++ b/types/src/execution/execution_result.rs @@ -90,7 +90,10 @@ impl ExecutionResult { ExecutionResultV1::Success { .. } => None, }, ExecutionResult::V2(v2) => v2.error_message.clone(), - ExecutionResult::Evm(evm) => evm.receipt.status.message().map(str::to_string), + ExecutionResult::Evm(evm) => evm + .error_message + .clone() + .or_else(|| evm.receipt.status.message().map(str::to_string)), } } From 01666268c2a21327c0872fcfd2539f91648e43dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:32:04 +0200 Subject: [PATCH 07/19] Test fractional-mote EVM value rejection Send a validly signed transaction whose value cannot be represented by a Casper purse. The transaction must fail without stopping the reactor, charging fees, advancing the nonce, or changing total supply. --- .../main_reactor/tests/transactions.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/node/src/reactor/main_reactor/tests/transactions.rs b/node/src/reactor/main_reactor/tests/transactions.rs index 721f2b2bce..0f310336a1 100644 --- a/node/src/reactor/main_reactor/tests/transactions.rs +++ b/node/src/reactor/main_reactor/tests/transactions.rs @@ -1341,6 +1341,26 @@ async fn assert_evm_transaction_validation_failure_is_not_fatal( ); } +#[tokio::test] +async fn should_not_fatally_exit_for_evm_transaction_with_fractional_mote_value() { + let transaction = TxLegacy { + chain_id: Some(0x4353_50FF), + nonce: 0, + gas_price: EVM_TEST_GAS_PRICE, + gas_limit: 21_000, + to: TxKind::Call(AlloyAddress::repeat_byte(0x22)), + value: U256::ONE, + input: AlloyBytes::new(), + }; + assert_evm_transaction_validation_failure_is_not_fatal( + transaction, + 0, + None, + "is not an exact number of motes", + ) + .await; +} + #[tokio::test] async fn should_not_fatally_exit_for_evm_transaction_below_intrinsic_gas() { let transaction = TxLegacy { From 9ef4cda8ece5563823b2feee7edd037496585f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:32:11 +0200 Subject: [PATCH 08/19] Validate EVM values before balance checks Run EVM request validation before required-balance calculation. A value that contains fractional motes can then become a transaction-scoped precondition failure instead of a fatal payment conversion error. --- .../components/contract_runtime/operations.rs | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/node/src/components/contract_runtime/operations.rs b/node/src/components/contract_runtime/operations.rs index 8007dda3ba..d6733024e5 100644 --- a/node/src/components/contract_runtime/operations.rs +++ b/node/src/components/contract_runtime/operations.rs @@ -1073,31 +1073,12 @@ pub fn execute_finalized_block( let is_valid_evm_request; let allow_execution = { let is_not_penalized = !balance_identifier.is_penalty(); - // in the case of custom payment, we do all payment processing up front after checking - // if the initiator can cover the penalty payment, and then either charge the full - // amount in the happy path or the penalty amount in the sad path...in whichever case - // the sad path is handled by is_penalty and the balance in the payment purse is - // the penalty payment or the full amount but is 'sufficient' either way - let actual_cost = artifact_builder.actual_cost(); // use actual cost here - let required_balance = if let Some(evm_transaction) = evm_transaction { - evm_transaction - .required_balance(actual_cost, &chainspec.evm_config) - .ok_or_else(|| { - BlockExecutionError::PaymentError( - "EVM value is not an exact mote amount or value plus fee overflowed U512" - .to_string(), - ) - })? - } else { - actual_cost - }; - let is_sufficient_balance = - is_custom_payment || post_payment_balance_result.is_sufficient(required_balance); - let is_allowed_by_chainspec = chainspec.is_supported(lane_id); // Transactions are accepted against a shared pre-state, but execute sequentially // against the evolving block state. Run all revm transaction preconditions here so // any state-dependent mismatch becomes a per-transaction failure before a payment - // hold or other durable effect is created. + // hold or other durable effect is created. This must also precede required-balance + // calculation: EVM validation rejects values that cannot be represented as motes, + // whereas required-balance calculation cannot report a transaction-scoped error. is_valid_evm_request = if let (Some(evm_transaction), Some(origin_resolution)) = (evm_transaction, evm_origin_resolution.as_ref()) { @@ -1130,6 +1111,31 @@ pub fn execute_finalized_block( } else { true }; + // in the case of custom payment, we do all payment processing up front after checking + // if the initiator can cover the penalty payment, and then either charge the full + // amount in the happy path or the penalty amount in the sad path...in whichever case + // the sad path is handled by is_penalty and the balance in the payment purse is + // the penalty payment or the full amount but is 'sufficient' either way + let actual_cost = artifact_builder.actual_cost(); // use actual cost here + let is_sufficient_balance = if is_valid_evm_request { + let required_balance = if let Some(evm_transaction) = evm_transaction { + evm_transaction + .required_balance(actual_cost, &chainspec.evm_config) + .ok_or_else(|| { + BlockExecutionError::PaymentError( + "EVM value is not an exact mote amount or value plus fee overflowed U512" + .to_string(), + ) + })? + } else { + actual_cost + }; + is_custom_payment || post_payment_balance_result.is_sufficient(required_balance) + } else { + // Preserve the EVM validation error already recorded on the artifact builder. + true + }; + let is_allowed_by_chainspec = chainspec.is_supported(lane_id); let allow = is_not_penalized && is_sufficient_balance && is_allowed_by_chainspec From ae2185e9fbb9c0598a70c7f8d19c601be7ae9b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:32:25 +0200 Subject: [PATCH 09/19] Test positive EVM priority fee rejection Exercise a signed transaction that offers an effective priority fee on a network that does not support transaction prioritization. It must be rejected without reactor failure or state changes. --- .../main_reactor/tests/transactions.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/node/src/reactor/main_reactor/tests/transactions.rs b/node/src/reactor/main_reactor/tests/transactions.rs index 0f310336a1..68b76cdf7b 100644 --- a/node/src/reactor/main_reactor/tests/transactions.rs +++ b/node/src/reactor/main_reactor/tests/transactions.rs @@ -1361,6 +1361,26 @@ async fn should_not_fatally_exit_for_evm_transaction_with_fractional_mote_value( .await; } +#[tokio::test] +async fn should_reject_evm_transaction_with_positive_effective_priority_fee() { + let transaction = TxLegacy { + chain_id: Some(0x4353_50FF), + nonce: 0, + gas_price: EVM_TEST_GAS_PRICE + 1, + gas_limit: 21_000, + to: TxKind::Call(AlloyAddress::repeat_byte(0x22)), + value: U256::ZERO, + input: AlloyBytes::new(), + }; + assert_evm_transaction_validation_failure_is_not_fatal( + transaction, + 0, + None, + "effective priority fee per gas 1 is unsupported", + ) + .await; +} + #[tokio::test] async fn should_not_fatally_exit_for_evm_transaction_below_intrinsic_gas() { let transaction = TxLegacy { From a0bae895845f94fe127d84e751ca187368bba758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:32:32 +0200 Subject: [PATCH 10/19] Recheck EVM policy during block execution Repeat transaction-acceptor chainspec checks in the execution precondition path. Proposed transactions can bypass the acceptor, so policy violations must still become per-transaction failures. --- .../components/contract_runtime/operations.rs | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/node/src/components/contract_runtime/operations.rs b/node/src/components/contract_runtime/operations.rs index d6733024e5..70e5640bfc 100644 --- a/node/src/components/contract_runtime/operations.rs +++ b/node/src/components/contract_runtime/operations.rs @@ -50,7 +50,8 @@ use casper_types::{ BlockHash, BlockHeader, BlockTime, BlockV2, CLValue, Chainspec, ChecksumRegistry, Digest, EntityAddr, EraEndV2, EraId, EvmTransactionError as CasperEvmTransactionError, FeeHandling, Gas, InvalidTransaction, InvalidTransactionV1, Key, ProtocolVersion, PublicKey, RefundHandling, - StoredValue, TimeDiff, Transaction, TransactionEntryPoint, AUCTION_LANE_ID, MINT_LANE_ID, U512, + StoredValue, TimeDiff, Timestamp, Transaction, TransactionEntryPoint, AUCTION_LANE_ID, + MINT_LANE_ID, U512, }; use super::{ @@ -148,24 +149,42 @@ fn evm_transaction_precondition_failure( data_access_layer: &DataAccessLayer, tracking_copy: &mut TrackingCopy, protocol_version: ProtocolVersion, - evm_config: casper_types::EvmConfig, + chainspec: &Chainspec, block_context: EvmBlockContext, - transaction: &casper_types::EvmTransaction, + transaction: &MetaTransaction, identity_plan: EvmIdentityPlan, ) -> Result, BlockExecutionError> where R: StateReader, { + let evm_transaction = transaction + .as_evm() + .ok_or(BlockExecutionError::InvalidTransactionVariant)?; + // Transactions obtained while validating a proposed block do not necessarily pass through + // the transaction acceptor. Repeat its chainspec checks so network-reachable policy failures + // are recorded on the transaction instead of reaching payment or execution. + let block_timestamp = Timestamp::from(block_context.timestamp.saturating_mul(1_000)); + if let Err(error) = + transaction.is_config_compliant(chainspec, TimeDiff::default(), block_timestamp) + { + return match error { + InvalidTransaction::Evm(error) => Ok(Some(error)), + error => Err(BlockExecutionError::TransactionConversion( + error.to_string(), + )), + }; + } + // Execution applies this plan immediately before entering the EVM. Apply it // only to this disposable tracking copy so validation sees the same caller // identity without committing identity state for a rejected transaction. apply_evm_identity_plan(tracking_copy, protocol_version, identity_plan)?; - let result = EvmExecutor::new(evm_config).validate_transaction( + let result = EvmExecutor::new(chainspec.evm_config).validate_transaction( data_access_layer, tracking_copy, block_context, - transaction, + evm_transaction, ); let invalid_request = match result { Ok(()) => return Ok(None), @@ -1074,12 +1093,12 @@ pub fn execute_finalized_block( let allow_execution = { let is_not_penalized = !balance_identifier.is_penalty(); // Transactions are accepted against a shared pre-state, but execute sequentially - // against the evolving block state. Run all revm transaction preconditions here so + // against the evolving block state. Run all EVM transaction preconditions here so // any state-dependent mismatch becomes a per-transaction failure before a payment // hold or other durable effect is created. This must also precede required-balance // calculation: EVM validation rejects values that cannot be represented as motes, // whereas required-balance calculation cannot report a transaction-scoped error. - is_valid_evm_request = if let (Some(evm_transaction), Some(origin_resolution)) = + is_valid_evm_request = if let (Some(_), Some(origin_resolution)) = (evm_transaction, evm_origin_resolution.as_ref()) { let block_context = evm_block_context( @@ -1096,9 +1115,9 @@ pub fn execute_finalized_block( data_access_layer, &mut tracking_copy, protocol_version, - chainspec.evm_config, + chainspec, block_context, - evm_transaction, + &transaction, origin_resolution.identity_plan, )? { Some(invalid_request) => { From 1ffb1c7c39619644abe5629539263e252be94d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:33:32 +0200 Subject: [PATCH 11/19] Test self-destructed EVM dust accounting Transfer one wei from a newly created contract and self-destruct it in the same transaction. Require execution to succeed, report one mote of dust, preserve supply for later consumption, and avoid reactor failure. --- executor/evm/tests/executor.rs | 46 ++++++++ .../main_reactor/tests/transactions.rs | 105 ++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/executor/evm/tests/executor.rs b/executor/evm/tests/executor.rs index 906efc77af..9df0c2dafd 100644 --- a/executor/evm/tests/executor.rs +++ b/executor/evm/tests/executor.rs @@ -345,6 +345,13 @@ fn one_wei_transfer_init_code(recipient: evm::Address, terminal: &[u8]) -> Vec Vec { + let mut init_code = Vec::new(); + append_one_wei_call(&mut init_code, recipient); + init_code.extend([opcode::ADDRESS, opcode::SELFDESTRUCT]); + init_code +} + fn return_call_value_to_caller_init_code() -> Vec { let runtime = vec![ opcode::PUSH1, @@ -1603,6 +1610,45 @@ fn internal_one_wei_transfer_reports_one_aggregate_dust_mote() { assert_eq!(read_balance(&mut tracking_copy, recipient), U512::zero()); } +#[test] +fn selfdestruct_after_one_wei_transfer_reports_one_dust_mote() { + let executor = executor(EvmSpec::Prague); + let recipient = evm::Address::new([0x42; 20]); + let (mut tracking_copy, data_access_layer, _tempdir) = tracking_copy(); + let tx = TxLegacy { + chain_id: Some(7), + nonce: 0, + gas_price: 1, + gas_limit: 200_000, + to: TxKind::Create, + value: U256::from(DEFAULT_WEI_PER_MOTE), + input: one_wei_transfer_then_selfdestruct_init_code(recipient).into(), + }; + let tx = tx.into_signed(Signature::test_signature().with_parity(true)); + let transaction = EvmTransaction::from_signed_rlp( + TxEnvelope::from(tx).encoded_2718(), + Timestamp::zero(), + casper_types::TimeDiff::from_seconds(60), + ) + .expect("transaction should decode"); + seed_evm_balance(&mut tracking_copy, transaction.from(), U512::from(10u64)); + + let outcome = executor + .execute( + &data_access_layer, + &mut tracking_copy, + ExecuteRequest { + block: block(), + kind: ExecuteKind::Transaction(Box::new(transaction)), + }, + ) + .expect("self-destructed wei and final balance remainders should aggregate into motes"); + + assert_eq!(outcome.status, ExecutionStatus::Success); + assert_eq!(outcome.dust_motes, U512::one()); + assert_eq!(read_balance(&mut tracking_copy, recipient), U512::zero()); +} + #[test] fn recombined_internal_wei_produces_no_dust() { let executor = executor(EvmSpec::Prague); diff --git a/node/src/reactor/main_reactor/tests/transactions.rs b/node/src/reactor/main_reactor/tests/transactions.rs index 68b76cdf7b..82b38e8929 100644 --- a/node/src/reactor/main_reactor/tests/transactions.rs +++ b/node/src/reactor/main_reactor/tests/transactions.rs @@ -906,6 +906,33 @@ fn evm_init_code_returning(runtime: Vec) -> Vec { init_code } +fn evm_one_wei_transfer_then_selfdestruct_init_code(recipient: AlloyAddress) -> Vec { + let mut init_code = vec![ + opcode::PUSH1, + 0, // return size + opcode::PUSH1, + 0, // return offset + opcode::PUSH1, + 0, // calldata size + opcode::PUSH1, + 0, // calldata offset + opcode::PUSH1, + 1, // value + opcode::PUSH20, + ]; + init_code.extend_from_slice(recipient.as_slice()); + init_code.extend([ + opcode::PUSH2, + 0xff, + 0xff, // gas + opcode::CALL, + opcode::POP, + opcode::ADDRESS, + opcode::SELFDESTRUCT, + ]); + init_code +} + fn evm_coinbase_transfer_init_code() -> Vec { let revert_offset = 19u8; evm_init_code_returning(vec![ @@ -1640,6 +1667,84 @@ async fn should_not_fatally_exit_for_competing_evm_transactions_with_the_same_no ); } +#[tokio::test] +async fn should_not_fatally_exit_for_selfdestruct_after_one_wei_transfer() { + let evm_config = EvmConfig { + enabled: true, + chain_id: 0x4353_50FF, + spec: EvmSpec::Prague, + block_gas_limit: 30_000_000, + base_fee: 1, + wei_per_mote: DEFAULT_WEI_PER_MOTE, + }; + let config = SingleTransactionTestCase::default_test_config() + .with_evm_config(evm_config) + .with_refund_handling(RefundHandling::NoRefund) + .with_fee_handling(FeeHandling::Burn); + let mut test = SingleTransactionTestCase::new( + Arc::clone(&ALICE_SECRET_KEY), + Arc::clone(&BOB_SECRET_KEY), + Arc::clone(&CHARLIE_SECRET_KEY), + Some(config), + ) + .await; + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + let recipient = AlloyAddress::repeat_byte(0x42); + let evm_transaction = signed_evm_legacy_transaction(TxLegacy { + chain_id: Some(evm_config.chain_id), + nonce: 0, + gas_price: EVM_TEST_GAS_PRICE, + gas_limit: 200_000, + to: TxKind::Create, + value: U256::from(DEFAULT_WEI_PER_MOTE), + input: AlloyBytes::from(evm_one_wei_transfer_then_selfdestruct_init_code(recipient)), + }); + let sender = evm_transaction.from(); + let initial_balance = U512::from(EVM_INITIAL_BALANCE); + seed_evm_account(&mut test.fixture, sender, initial_balance); + let initial_total_supply = test.get_total_supply(None); + let max_fee_amount = evm_transaction + .max_fee_amount(&evm_config) + .expect("maximum EVM fee should fit"); + + let (_transaction_hash, block_height, execution_result) = test + .send_transaction(Transaction::from(evm_transaction)) + .await; + let ExecutionResult::Evm(execution_result) = execution_result else { + panic!("expected EVM execution result"); + }; + assert!(execution_result.error_message.is_none()); + assert!(execution_result.receipt.status.is_success()); + assert!(execution_result.receipt.gas_used > 0); + assert!(!execution_result.effects.is_empty()); + + assert_eq!( + evm_account_at(&mut test.fixture, block_height, sender).nonce(), + 1 + ); + assert_eq!( + evm_balance(&mut test.fixture, sender, block_height), + initial_balance - max_fee_amount - U512::one() + ); + assert_eq!( + evm_balance( + &mut test.fixture, + alloy_address_to_evm_address(recipient), + block_height, + ), + U512::zero() + ); + // Dust is only reported by the executor for now. The fee is burned, but the discarded mote + // remains in total supply until a later consumer handles the outcome's dust amount. + assert_eq!( + test.get_total_supply(Some(block_height)), + initial_total_supply - max_fee_amount + ); +} + #[tokio::test] async fn should_execute_evm_transaction_and_store_receipt() { let evm_config = EvmConfig { From 6e45e4f34d6449a4198a748395d7a8c592a9b3e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:33:40 +0200 Subject: [PATCH 12/19] Account for self-destructed EVM dust Combine balances removed by self-destruct with remaining wei before converting the execution outcome to motes. Avoid pruning state keys that were created and destroyed within the same transaction. --- executor/evm/src/state.rs | 65 +++++++++++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/executor/evm/src/state.rs b/executor/evm/src/state.rs index 2de884826c..89bcd784d0 100644 --- a/executor/evm/src/state.rs +++ b/executor/evm/src/state.rs @@ -74,7 +74,7 @@ fn apply_account( where R: StateReader, { - // Check how to deal with Key::Balance after selfdestruct + // A self-destruct removes persisted EVM state while preserving any linked Casper account. let address = tx::from_revm_address(address); let account_key = Key::Evm(EvmAddr::Account(address)); @@ -149,12 +149,29 @@ where for key in storage_keys { tracking_copy.prune(key); } + // A contract created and destroyed within one transaction has no persisted balance or EVM + // metadata to remove. Avoid emitting prunes for those missing keys: scratch-state commits + // reject such transforms instead of treating them as no-ops. if !matches!(identity, Some(account_state::AccountIdentity::Account(_))) { - tracking_copy.prune(Key::Balance(main_purse.addr())); + prune_if_exists(tracking_copy, Key::Balance(main_purse.addr()))?; + } + prune_if_exists(tracking_copy, account_key)?; + prune_if_exists(tracking_copy, Key::Evm(EvmAddr::Nonce(address)))?; + prune_if_exists(tracking_copy, Key::Evm(EvmAddr::CodeHash(address)))?; + Ok(()) +} + +fn prune_if_exists(tracking_copy: &mut TrackingCopy, key: Key) -> Result<(), Error> +where + R: StateReader, +{ + if tracking_copy + .read(&key) + .map_err(|error| Error::State(error.to_string()))? + .is_some() + { + tracking_copy.prune(key); } - tracking_copy.prune(account_key); - tracking_copy.prune(Key::Evm(EvmAddr::Nonce(address))); - tracking_copy.prune(Key::Evm(EvmAddr::CodeHash(address))); Ok(()) } @@ -214,9 +231,21 @@ fn resolve_balances( let wei_per_mote = U512::from(wei_per_mote); let mut balances = AddressMap::with_capacity_and_hasher(state.len(), Default::default()); let mut aggregate_remainder_wei = U512::zero(); + let mut aggregate_original_balance_wei = U512::zero(); + let mut aggregate_final_balance_wei = U512::zero(); for (address, account) in state { let balance_wei = u256_to_u512(account.info.balance); balances.insert(*address, balance_wei / wei_per_mote); + aggregate_original_balance_wei = aggregate_original_balance_wei + .checked_add(u256_to_u512(account.original_info.balance)) + .ok_or_else(|| { + Error::State("aggregate original EVM balance overflowed U512".to_string()) + })?; + aggregate_final_balance_wei = aggregate_final_balance_wei + .checked_add(balance_wei) + .ok_or_else(|| { + Error::State("aggregate final EVM balance overflowed U512".to_string()) + })?; aggregate_remainder_wei = aggregate_remainder_wei .checked_add(balance_wei % wei_per_mote) .ok_or_else(|| { @@ -224,14 +253,32 @@ fn resolve_balances( })?; } - if aggregate_remainder_wei % wei_per_mote != U512::zero() { + // A self-destruct can remove wei which never appear in the final account balances. Combine + // that amount with the remainders discarded while converting final balances to motes. The + // original balances came from mote-denominated Casper purses, so the combined amount must be + // an exact number of motes even when neither component is independently representable. + let destroyed_balance_wei = aggregate_original_balance_wei + .checked_sub(aggregate_final_balance_wei) + .ok_or_else(|| { + Error::State(format!( + "aggregate EVM balance increased from {aggregate_original_balance_wei} wei to \ + {aggregate_final_balance_wei} wei" + )) + })?; + let discarded_balance_wei = aggregate_remainder_wei + .checked_add(destroyed_balance_wei) + .ok_or_else(|| { + Error::State("aggregate discarded EVM balance overflowed U512".to_string()) + })?; + + if discarded_balance_wei % wei_per_mote != U512::zero() { return Err(Error::State(format!( - "aggregate EVM balance remainder {aggregate_remainder_wei} wei is not divisible by \ + "aggregate discarded EVM balance {discarded_balance_wei} wei is not divisible by \ {wei_per_mote} wei per mote" ))); } - Ok((balances, aggregate_remainder_wei / wei_per_mote)) + Ok((balances, discarded_balance_wei / wei_per_mote)) } #[cfg(test)] @@ -277,7 +324,7 @@ mod tests { assert!(matches!( resolve_balances(&state, 10), Err(Error::State(message)) - if message.contains("aggregate EVM balance remainder 1 wei is not divisible") + if message.contains("aggregate discarded EVM balance 1 wei is not divisible") )); } } From fbb00d443acc9634c18a4d70d445bb51166eece6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:34:03 +0200 Subject: [PATCH 13/19] Test fetched fractional EVM value rejection Show that signature validation alone accepts a peer-fetched transaction whose value cannot be represented in motes. Block validation must still reject that transaction before accepting the proposal. --- node/src/components/block_validator/tests.rs | 100 ++++++++++++++++++- 1 file changed, 95 insertions(+), 5 deletions(-) diff --git a/node/src/components/block_validator/tests.rs b/node/src/components/block_validator/tests.rs index d97da1f41a..58294e19b5 100644 --- a/node/src/components/block_validator/tests.rs +++ b/node/src/components/block_validator/tests.rs @@ -1,21 +1,28 @@ use std::{collections::VecDeque, sync::Arc, time::Duration}; +use alloy_consensus::{SignableTransaction, TxEnvelope, TxLegacy}; +use alloy_eips::Encodable2718; +use alloy_primitives::{ + Address as AlloyAddress, Bytes as AlloyBytes, Signature as AlloySignature, TxKind, + U256 as AlloyU256, +}; use derive_more::From; use itertools::Itertools; +use k256::ecdsa::{signature::hazmat::PrehashSigner, SigningKey}; use rand::Rng; use casper_types::{ bytesrepr::Bytes, runtime_args, system::standard_payment::ARG_AMOUNT, testing::TestRng, Block, - BlockSignatures, BlockSignaturesV2, Chainspec, ChainspecRawBytes, Deploy, ExecutableDeployItem, - FinalitySignatureV2, RuntimeArgs, SecretKey, TestBlockBuilder, TimeDiff, Transaction, - TransactionHash, TransactionId, TransactionV1, TransactionV1Config, AUCTION_LANE_ID, - INSTALL_UPGRADE_LANE_ID, MINT_LANE_ID, U512, + BlockSignatures, BlockSignaturesV2, Chainspec, ChainspecRawBytes, Deploy, EvmTransaction, + ExecutableDeployItem, FinalitySignatureV2, RuntimeArgs, SecretKey, TestBlockBuilder, TimeDiff, + Transaction, TransactionHash, TransactionId, TransactionV1, TransactionV1Config, + AUCTION_LANE_ID, INSTALL_UPGRADE_LANE_ID, MINT_LANE_ID, U512, }; use crate::{ components::{ consensus::BlockContext, - fetcher::{self, FetchItem}, + fetcher::{self, EmptyValidationMetadata, FetchItem}, }, effect::requests::StorageRequest, reactor::{EventQueueHandle, QueueKind, Scheduler}, @@ -278,6 +285,37 @@ pub(super) fn new_standard(rng: &mut TestRng, timestamp: Timestamp, ttl: TimeDif } } +fn signed_evm_legacy_transaction( + chain_id: u64, + timestamp: Timestamp, + gas_price: u128, + value: AlloyU256, +) -> Transaction { + let transaction = TxLegacy { + chain_id: Some(chain_id), + nonce: 0, + gas_price, + gas_limit: 21_000, + to: TxKind::Call(AlloyAddress::repeat_byte(0x22)), + value, + input: AlloyBytes::new(), + }; + let signing_key = + SigningKey::from_slice(&[0x11; 32]).expect("test EVM private key should be valid"); + let (signature, recovery_id) = signing_key + .sign_prehash(transaction.signature_hash().as_ref()) + .expect("test EVM transaction signing should succeed"); + let signed = transaction.into_signed(AlloySignature::from((signature, recovery_id))); + Transaction::from( + EvmTransaction::from_signed_rlp( + TxEnvelope::from(signed).encoded_2718(), + timestamp, + TimeDiff::from_seconds(60), + ) + .expect("test EVM transaction should decode"), + ) +} + pub(super) fn new_non_transfer( rng: &mut TestRng, timestamp: Timestamp, @@ -790,6 +828,58 @@ async fn empty_block() { assert!(empty_context.proposal_is_valid(&mut rng, 1000.into()).await); } +async fn assert_peer_fetched_evm_transaction_is_rejected( + rng: &mut TestRng, + timestamp: Timestamp, + make_transaction: impl FnOnce(&Chainspec) -> Transaction, + expected_error_fragment: &str, +) { + let mut context = ValidationContext::new().with_num_validators(rng, 1); + context.chainspec.evm_config.enabled = true; + let transaction = make_transaction(&context.chainspec); + + // This is the validation performed when the transaction is fetched from a peer. A valid + // signature is not sufficient to establish chainspec compliance. + transaction + .validate(&EmptyValidationMetadata) + .expect("peer fetch validation should accept the signed transaction"); + + let mut context = context + .with_transactions(vec![transaction]) + .include_all_transactions(); + let error = context + .validate_proposed_block(rng, timestamp) + .await + .expect_err("block validation should reject the chainspec-incompliant EVM transaction"); + match *error { + InvalidProposalError::InvalidTransaction(message) => assert!( + message.contains(expected_error_fragment), + "unexpected block validation error: {message}" + ), + error => panic!("unexpected block validation error: {error:?}"), + } +} + +#[tokio::test] +async fn should_reject_peer_fetched_evm_transaction_with_fractional_mote_value() { + let mut rng = TestRng::new(); + let timestamp = Timestamp::from(1_000); + assert_peer_fetched_evm_transaction_is_rejected( + &mut rng, + timestamp, + |chainspec| { + signed_evm_legacy_transaction( + chainspec.evm_config.chain_id, + timestamp, + chainspec.evm_config.base_fee_wei(), + AlloyU256::ONE, + ) + }, + "is not an exact number of motes", + ) + .await; +} + /// Verifies that the block validator checks transaction and transfer timestamps and ttl. #[tokio::test] async fn ttl() { From 0efa13e21c372d235c4d31525ff1168ca7a7ed69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:34:22 +0200 Subject: [PATCH 14/19] Test fetched EVM priority fee rejection Show that peer fetch validation accepts a correctly signed transaction with an unsupported effective priority fee. Require proposal validation to apply the missing chainspec policy check. --- node/src/components/block_validator/tests.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/node/src/components/block_validator/tests.rs b/node/src/components/block_validator/tests.rs index 58294e19b5..cde29c1e0e 100644 --- a/node/src/components/block_validator/tests.rs +++ b/node/src/components/block_validator/tests.rs @@ -880,6 +880,26 @@ async fn should_reject_peer_fetched_evm_transaction_with_fractional_mote_value() .await; } +#[tokio::test] +async fn should_reject_peer_fetched_evm_transaction_with_positive_effective_priority_fee() { + let mut rng = TestRng::new(); + let timestamp = Timestamp::from(1_000); + assert_peer_fetched_evm_transaction_is_rejected( + &mut rng, + timestamp, + |chainspec| { + signed_evm_legacy_transaction( + chainspec.evm_config.chain_id, + timestamp, + chainspec.evm_config.base_fee_wei() + 1, + AlloyU256::ZERO, + ) + }, + "effective priority fee per gas 1 is unsupported", + ) + .await; +} + /// Verifies that the block validator checks transaction and transfer timestamps and ttl. #[tokio::test] async fn ttl() { From b3852121156f7c94cb2523847f6bce1dbed5618e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:34:36 +0200 Subject: [PATCH 15/19] Reject invalid EVM transactions before buffering Apply EVM chainspec compliance while creating transaction footprints. This covers both the transaction buffer and proposed-block validation, so peer-fetched policy violations never reach block execution. --- .../main_reactor/tests/transactions.rs | 95 +++++++++++++++---- .../transaction/transaction_footprint.rs | 6 ++ 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/node/src/reactor/main_reactor/tests/transactions.rs b/node/src/reactor/main_reactor/tests/transactions.rs index 82b38e8929..24dbb66ede 100644 --- a/node/src/reactor/main_reactor/tests/transactions.rs +++ b/node/src/reactor/main_reactor/tests/transactions.rs @@ -1368,8 +1368,83 @@ async fn assert_evm_transaction_validation_failure_is_not_fatal( ); } +async fn assert_evm_transaction_is_rejected_before_execution(transaction: TxLegacy) { + let evm_config = EvmConfig { + enabled: true, + chain_id: transaction + .chain_id + .expect("test transaction should have a chain ID"), + spec: EvmSpec::Prague, + block_gas_limit: 30_000_000, + base_fee: 1, + wei_per_mote: DEFAULT_WEI_PER_MOTE, + }; + let config = SingleTransactionTestCase::default_test_config() + .with_evm_config(evm_config) + .with_refund_handling(RefundHandling::NoRefund) + .with_fee_handling(FeeHandling::Burn); + let mut test = SingleTransactionTestCase::new( + Arc::clone(&ALICE_SECRET_KEY), + Arc::clone(&BOB_SECRET_KEY), + Arc::clone(&CHARLIE_SECRET_KEY), + Some(config), + ) + .await; + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + let evm_transaction = signed_evm_legacy_transaction(transaction); + let transaction_hash = TransactionHash::from(evm_transaction.hash()); + let sender = evm_transaction.from(); + seed_evm_account(&mut test.fixture, sender, U512::from(EVM_INITIAL_BALANCE)); + let initial_total_supply = test.get_total_supply(None); + let initial_block_height = test + .fixture + .network + .nodes() + .values() + .next() + .expect("network should contain a node") + .main_reactor() + .storage() + .highest_complete_block_height() + .expect("network should have a completed block"); + + test.fixture + .inject_transaction(Transaction::from(evm_transaction)) + .await; + let final_block_height = initial_block_height + 2; + test.fixture + .run_until_block_height(final_block_height, ONE_MIN) + .await; + + for runner in test.fixture.network.nodes().values() { + assert!( + runner + .main_reactor() + .storage() + .read_execution_info(transaction_hash) + .is_none(), + "chainspec-incompliant EVM transaction should not be included in a block" + ); + } + assert_eq!( + evm_account_at(&mut test.fixture, final_block_height, sender).nonce(), + 0 + ); + assert_eq!( + evm_balance(&mut test.fixture, sender, final_block_height), + U512::from(EVM_INITIAL_BALANCE) + ); + assert_eq!( + test.get_total_supply(Some(final_block_height)), + initial_total_supply + ); +} + #[tokio::test] -async fn should_not_fatally_exit_for_evm_transaction_with_fractional_mote_value() { +async fn should_reject_evm_transaction_with_fractional_mote_value_before_execution() { let transaction = TxLegacy { chain_id: Some(0x4353_50FF), nonce: 0, @@ -1379,17 +1454,11 @@ async fn should_not_fatally_exit_for_evm_transaction_with_fractional_mote_value( value: U256::ONE, input: AlloyBytes::new(), }; - assert_evm_transaction_validation_failure_is_not_fatal( - transaction, - 0, - None, - "is not an exact number of motes", - ) - .await; + assert_evm_transaction_is_rejected_before_execution(transaction).await; } #[tokio::test] -async fn should_reject_evm_transaction_with_positive_effective_priority_fee() { +async fn should_reject_evm_transaction_with_positive_effective_priority_fee_before_execution() { let transaction = TxLegacy { chain_id: Some(0x4353_50FF), nonce: 0, @@ -1399,13 +1468,7 @@ async fn should_reject_evm_transaction_with_positive_effective_priority_fee() { value: U256::ZERO, input: AlloyBytes::new(), }; - assert_evm_transaction_validation_failure_is_not_fatal( - transaction, - 0, - None, - "effective priority fee per gas 1 is unsupported", - ) - .await; + assert_evm_transaction_is_rejected_before_execution(transaction).await; } #[tokio::test] diff --git a/node/src/types/transaction/transaction_footprint.rs b/node/src/types/transaction/transaction_footprint.rs index 9b28ea04c1..5f325fd7bb 100644 --- a/node/src/types/transaction/transaction_footprint.rs +++ b/node/src/types/transaction/transaction_footprint.rs @@ -65,6 +65,12 @@ impl TransactionFootprint { InvalidTransactionV1::InvalidTransactionLane(lane_id), )); } + // A transaction fetched while validating a proposed block may not have passed through the + // transaction acceptor. EVM chainspec compliance does not depend on the validation time, + // so enforce it here before the transaction can enter a block or the transaction buffer. + if matches!(transaction, MetaTransaction::Evm(_)) { + transaction.is_config_compliant(chainspec, TimeDiff::ZERO, transaction.timestamp())?; + } let transaction_hash = transaction.hash(); let size_estimate = transaction.size_estimate(); let payload_hash = transaction.payload_hash(); From b070a7517f3069927c597ad1de9e8da3209f39b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Thu, 17 Sep 2026 16:34:42 +0200 Subject: [PATCH 16/19] Document network input failure handling Tell future changes to reject malformed network-reachable input or record per-item failures. Reserve fatal reactor announcements for internal invariants and unrecoverable local state. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 5157298c44..6a581c5c4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,4 @@ - If Wasm contract fixtures are missing, run `make build-contracts-rs`. - Keep `resources/local/chainspec.toml.in` in sync when editing chainspecs; run `./generate-chainspec.sh` when `resources/local/chainspec.toml` is missing or stale. - Treat idempotent system contract/predeploy upserts in protocol upgrade handlers as standard activation behavior, not as an alternative to `global_state_update`. +- Do not turn malformed or invalid network-reachable input, including fetched transactions and proposed blocks, into a fatal reactor error. Reject it or record a per-item failure; reserve fatal announcements for internal invariants or unrecoverable local state. From 9b78ff0445341c68c6df97b45c8e7f86eb3c96f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Wed, 16 Sep 2026 15:09:48 +0200 Subject: [PATCH 17/19] Add EVM network resource configuration Provide a testnet-derived chainspec with EVM and addressable entities enabled. Add a mainnet-derived node config with speculative execution and temporary higher request limits for sidecar. Update the devnet documentation to consume the checked-in EVM resources directly. --- EVM.md | 30 +- resources/README.md | 1 + resources/evm/chainspec.toml | 527 ++++++++++++++++++++++++ resources/evm/config-example.toml | 661 ++++++++++++++++++++++++++++++ 4 files changed, 1199 insertions(+), 20 deletions(-) create mode 100644 resources/evm/chainspec.toml create mode 100644 resources/evm/config-example.toml diff --git a/EVM.md b/EVM.md index e890f709ab..8c83af56e9 100644 --- a/EVM.md +++ b/EVM.md @@ -749,19 +749,10 @@ cargo build -p casper-sidecar `casper-devnet` checkout, not from this workspace. The devnet tool needs a custom asset named `evm` that points at the debug node -and sidecar binaries built above, plus the local chainspec and config files -from this workspace. Use a node config where -`[binary_port_server].allow_request_speculative_exec = true`; the checked-in local -config defaults this to `false`, so copy `resources/local/config.toml` and -enable it in the copy used for this custom asset. - -For example: - -```bash -export EVM_DEVNET_NODE_CONFIG=/tmp/casper-node-evm-devnet-config.toml -cp "$CASPER_NODE_WORKSPACE/resources/local/config.toml" "$EVM_DEVNET_NODE_CONFIG" -# Edit $EVM_DEVNET_NODE_CONFIG so allow_request_speculative_exec = true. -``` +and sidecar binaries built above. Use the EVM-specific chainspec and node config +from `resources/evm`. They enable EVM execution, addressable entities, and +speculative execution and contain the higher temporary request limits required +by sidecar until its caching is improved. From a separate `casper-devnet` checkout, register the asset with: @@ -770,8 +761,8 @@ cd /path/to/casper-devnet cargo run -- assets add evm \ --casper-node "$CASPER_NODE_WORKSPACE/target/debug/casper-node" \ --casper-sidecar "$CASPER_SIDECAR_WORKSPACE/target/debug/casper-sidecar" \ - --chainspec "$CASPER_NODE_WORKSPACE/resources/local/chainspec.toml" \ - --node-config "$EVM_DEVNET_NODE_CONFIG" \ + --chainspec "$CASPER_NODE_WORKSPACE/resources/evm/chainspec.toml" \ + --node-config "$CASPER_NODE_WORKSPACE/resources/evm/config-example.toml" \ --sidecar-config "$CASPER_SIDECAR_WORKSPACE/resources/example_configs/default_rpc_only_config.toml" ``` @@ -781,8 +772,8 @@ If `casper-devnet` is already installed on `PATH`, the equivalent command is: casper-devnet assets add evm \ --casper-node "$CASPER_NODE_WORKSPACE/target/debug/casper-node" \ --casper-sidecar "$CASPER_SIDECAR_WORKSPACE/target/debug/casper-sidecar" \ - --chainspec "$CASPER_NODE_WORKSPACE/resources/local/chainspec.toml" \ - --node-config "$EVM_DEVNET_NODE_CONFIG" \ + --chainspec "$CASPER_NODE_WORKSPACE/resources/evm/chainspec.toml" \ + --node-config "$CASPER_NODE_WORKSPACE/resources/evm/config-example.toml" \ --sidecar-config "$CASPER_SIDECAR_WORKSPACE/resources/example_configs/default_rpc_only_config.toml" ``` @@ -804,8 +795,7 @@ After the `evm` asset is registered, start the network from any directory where the `casper-devnet` binary is available: ```bash -casper-devnet start --custom-asset evm --force-setup \ - --chainspec-override evm.enabled=true +casper-devnet start --custom-asset evm --force-setup ``` The `evm` custom asset uses the debug node binary from @@ -1207,4 +1197,4 @@ cargo build -p casper-sidecar [eip-7702]: https://eips.ethereum.org/EIPS/eip-7702 [eip-7840]: https://eips.ethereum.org/EIPS/eip-7840 [execution-apis]: https://ethereum.github.io/execution-apis/ -[geth-pubsub]: https://geth.ethereum.org/docs/interacting-with-geth/rpc/pubsub \ No newline at end of file +[geth-pubsub]: https://geth.ethereum.org/docs/interacting-with-geth/rpc/pubsub diff --git a/resources/README.md b/resources/README.md index 4b66044ce3..703de3f801 100644 --- a/resources/README.md +++ b/resources/README.md @@ -7,3 +7,4 @@ Resources artifacts for node-rs. * test: resources used by test fixtures * production: resources used by released software * local: resources used when running a node outside of a production environment; most typically used when running a node locally. +* evm: testnet-based resources for running an EVM-enabled network. diff --git a/resources/evm/chainspec.toml b/resources/evm/chainspec.toml new file mode 100644 index 0000000000..0375df7a88 --- /dev/null +++ b/resources/evm/chainspec.toml @@ -0,0 +1,527 @@ +[protocol] +# Protocol version. +version = '2.2.2' +# Whether we need to clear latest blocks back to the switch block just before the activation point or not. +hard_reset = true +# This protocol version becomes active at this point. +# +# If it is a timestamp string, it represents the timestamp for the genesis block. This is the beginning of era 0. By +# this time, a sufficient majority (> 50% + F/2 — see finality_threshold_fraction below) of validator nodes must be up +# and running to start the blockchain. This timestamp is also used in seeding the pseudo-random number generator used +# in contract-runtime for computing genesis post-state hash. +# +# If it is an integer, it represents an era ID, meaning the protocol version becomes active at the start of this era. +activation_point = 22690 + +[network] +# Human readable name for convenience; the genesis_hash is the true identifier. The name influences the genesis hash by +# contributing to the seeding of the pseudo-random number generator used in contract-runtime for computing genesis +# post-state hash. +name = 'casper-test' +# The maximum size of an acceptable networking message in bytes. Any message larger than this will +# be rejected at the networking level. +maximum_net_message_size = 25_165_824 + +[core] +# Era duration. +era_duration = '120 minutes' +# Minimum number of blocks per era. An era will take longer than `era_duration` if that is necessary to reach the +# minimum height. +minimum_era_height = 100 +# Minimum difference between a block's and its child's timestamp. +minimum_block_time = '8000 ms' +# Number of slots available in validator auction. +validator_slots = 100 +# A number between 0 and 1 representing the fault tolerance threshold as a fraction, used by the internal finalizer. +# It is the fraction of validators that would need to equivocate to make two honest nodes see two conflicting blocks as +# finalized: A higher value F makes it safer to rely on finalized blocks. It also makes it more difficult to finalize +# blocks, however, and requires strictly more than (F + 1)/2 validators to be working correctly. +finality_threshold_fraction = [1, 3] +# Protocol version from which nodes are required to hold strict finality signatures. +start_protocol_version_with_strict_finality_signatures_required = '1.5.0' +# Which finality is required for legacy blocks. Options are 'Strict', 'Weak' and 'Any'. +# Used to determine finality sufficiency for new joiners syncing blocks created +# in a protocol version before +# `start_protocol_version_with_strict_finality_signatures_required`. +legacy_required_finality = 'Any' +# Number of eras before an auction actually defines the set of validators. If you bond with a sufficient bid in era N, +# you will be a validator in era N + auction_delay + 1. +auction_delay = 1 +# The period after genesis during which a genesis validator's bid is locked. +locked_funds_period = '0 days' +# The period in which genesis validator's bid is released over time after it's unlocked. +vesting_schedule_period = '0 weeks' +# Default number of eras that need to pass to be able to withdraw unbonded funds. +unbonding_delay = 7 +# Round seigniorage rate represented as a fraction of the total supply. +# +# Annual issuance: 0.25% +# Minimum block time: 8000 milliseconds +# Ticks per year: 31536000000 +# +# (1+0.0025)^((8000)/31536000000)-1 is expressed as a fractional number below +# Python: +# from fractions import Fraction +# Fraction((1 + 0.0025)**((8000)/31536000000) - 1).limit_denominator(1000000000) +round_seigniorage_rate = [1, 1000000000] +# Maximum number of associated keys for a single account. +max_associated_keys = 100 +# Maximum height of contract runtime call stack. +max_runtime_call_stack_height = 12 +# Minimum allowed delegation amount in motes +minimum_delegation_amount = 500_000_000_000 +# Maximum allowed delegation amount in motes +maximum_delegation_amount = 1_000_000_000_000_000_000 +# Minimum bid amount allowed in motes. Withdrawing one's bid to an amount strictly less than +# the value specified will be treated as a full unbond of a validator and their associated delegators +minimum_bid_amount = 500_000_000_000 +# Global state prune batch size (0 = this feature is off) +prune_batch_size = 0 +# Enables strict arguments checking when calling a contract; i.e. that all non-optional args are provided and of the correct `CLType`. +strict_argument_checking = false +# Number of simultaneous peer requests. +simultaneous_peer_requests = 5 +# The consensus protocol to use. Options are "Zug" and "Highway". +consensus_protocol = 'Zug' +# The maximum amount of delegators per validator. +max_delegators_per_validator = 1200 +# Minimum delegation rate validators can specify (0-100). +minimum_delegation_rate = 0 +# The split in finality signature rewards between block producer and participating signers. +finders_fee = [1, 5] +# The proportion of baseline rewards going to reward finality signatures specifically. +finality_signature_proportion = [95, 100] +# Lookback interval indicating which past block we are looking at to reward. +signature_rewards_max_delay = 6 +# Allows transfers between accounts in the blockchain network. +# +# Setting this to false restricts normal accounts from sending tokens to other accounts, allowing transfers only to administrators. +# Changing this option makes sense only on private chains. +allow_unrestricted_transfers = true +# Enables the auction entry points 'delegate' and 'add_bid'. +# +# Setting this to false makes sense only for private chains which don't need to auction new validator slots. These +# auction entry points will return an error if called when this option is set to false. +allow_auction_bids = true +# If set to false, then consensus doesn't compute rewards and always uses 0. +compute_rewards = true +# Defines how refunds of the unused portion of payment amounts are calculated and handled. +# +# Valid options are: +# 'refund': a ratio of the unspent token is returned to the spender. +# 'burn': a ratio of the unspent token is burned. +# 'no_refund': no refunds are paid out; this is functionally equivalent to refund with 0% ratio. +# This causes excess payment amounts to be sent to either a +# pre-defined purse, or back to the sender. The refunded amount is calculated as the given ratio of the payment amount +# minus the execution costs. +refund_handling = { type = 'refund', refund_ratio = [75, 100] } +# Defines how fees are handled. +# +# Valid options are: +# 'no_fee': fees are eliminated. +# 'pay_to_proposer': fees are paid to the block proposer +# 'accumulate': fees are accumulated in a special purse and distributed at the end of each era evenly among all +# administrator accounts +# 'burn': fees are burned +fee_handling = { type = 'burn' } +# If a validator would recieve a validator credit, it cannot exceed this percentage of their total stake. +validator_credit_cap = [1, 5] +# Defines how pricing is handled. +# +# Valid options are: +# 'payment_limited': senders of transaction self-specify how much they pay. +# 'fixed': costs are fixed, per the cost table +# 'prepaid': prepaid transaction (currently not supported) +pricing_handling = { type = 'payment_limited' } +# Does the network allow pre-payment for future +# execution? Currently not supported. +# +allow_prepaid = false +# Defines how gas holds affect available balance calculations. +# +# Valid options are: +# 'accrued': sum of full value of all non-expired holds. +# 'amortized': sum of each hold is amortized over the time remaining until expiry. +# +# For instance, if 12 hours remained on a gas hold with a 24-hour `gas_hold_interval`, +# with accrued, the full hold amount would be applied +# with amortized, half the hold amount would be applied +gas_hold_balance_handling = { type = 'accrued' } +# Defines how long gas holds last. +# +# If fee_handling is set to 'no_fee', the system places a balance hold on the payer +# equal to the value the fee would have been. Such balance holds expire after a time +# interval has elapsed. This setting controls how long that interval is. The available +# balance of a purse equals its total balance minus the held amount(s) of non-expired +# holds (see gas_hold_balance_handling setting for details of how that is calculated). +# +# For instance, if gas_hold_interval is 24 hours and 100 gas is used from a purse, +# a hold for 100 is placed on that purse and is considered when calculating total balance +# for 24 hours starting from the block_time when the hold was placed. +gas_hold_interval = '24 hours' +# List of public keys of administrator accounts. Setting this option makes only on private chains which require +# administrator accounts for regulatory reasons. +administrators = [] +# Flag that triggers a migration of all userland accounts and contracts present in global state to the addressable +# entity in lazy manner. +# If the flag is set to false then no accounts and contracts are migrated during a protocol upgrade; +# i.e. all Account records will be present under Key::Account and Contracts and their associated ContractPackage +# will be written underneath Key::Hash. +# If the flag is set to true then accounts and contracts are migrated lazily; i.e on first use of the Account +# and/or Contract as part of the execution of a Transaction. This means the Accounts/Contracts will be migrated +# to their corresponding AddressableEntity and the NamedKeys for previous record and sepeareted and wrriten +# as discrete top level records. For Contracts specifically the entrypoints are also written as discrete top +# level records +# Note: Enabling of the AddressableEntity feature is one-way; i.e once enabled as part of a protocol upgrade +# the flag cannot be disabled in a future protocol upgrade. +enable_addressable_entity = true +# This value is used as the penalty payment amount, the lowest cost, and the minimum balance amount. +baseline_motes_amount = 2_500_000_000 +# Flag on whether ambiguous entity versions returns an execution error. +trap_on_ambiguous_entity_version = false +# Controls how rewards are handled by the network +# purse uref-b06a1ab0cfb52b5d4f9a08b68a5dbe78e999de0b0484c03e64f5c03897cf637b-007 belongs to +# account 018afa98ca4be12d613617f7339a2d576950a2f9a92102ca4d6508ee31b54d2c02 (faucet account for testnet) +rewards_handling = { type = 'sustain', ratio = [2,8], purse_address = "uref-b06a1ab0cfb52b5d4f9a08b68a5dbe78e999de0b0484c03e64f5c03897cf637b-007" } + + +[highway] +# Highway dynamically chooses its round length, between minimum_block_time and maximum_round_length. +maximum_round_length = '66 seconds' + +[transactions] +# The duration after the transaction timestamp that it can be included in a block. +max_ttl = '2 hours' +# The maximum number of approvals permitted in a single block. +block_max_approval_count = 2600 +# Maximum block size in bytes including transactions contained by the block. 0 means unlimited. +max_block_size = 2_621_400 +# The upper limit of total gas of all transactions in a block. +block_gas_limit = 812_500_000_000 +# The minimum amount in motes for a valid native transfer. +native_transfer_minimum_motes = 2_500_000_000 +# The maximum value to which `transaction_acceptor.timestamp_leeway` can be set in the config.toml file. +max_timestamp_leeway = '5 seconds' + +# Configuration of the transaction runtime. +[transactions.enabled_runtime] +vm_casper_v1 = true +vm_casper_v2 = false + +[transactions.v1] +# The configuration settings for the lanes of transactions including both native and Wasm based interactions. +# Currently the node supports two native interactions the mint and auction and have the reserved identifiers of 0 and 1 +# respectively +# The remaining wasm based lanes specify the range of configuration settings for a given Wasm based transaction +# within a given lane. +# The maximum length in bytes of runtime args per V1 transaction. +# [0] -> Transaction lane label (apart from the reserved native identifiers these are simply labels) +# Note: For the given mainnet implementation we specially reserve the label 2 for install and upgrades and +# the lane must be present and defined. +# Different casper networks may not impose such a restriction. +# [1] -> Max serialized length of the entire transaction in bytes for a given transaction in a certain lane +# [2] -> Max args length size in bytes for a given transaction in a certain lane +# [3] -> Transaction gas limit for a given transaction in a certain lane +# [4] -> The maximum number of transactions the lane can contain +native_mint_lane = [0, 2048, 1024, 100_000_000, 325] +native_auction_lane = [1, 3096, 2048, 2_500_000_000, 325] +install_upgrade_lane = [2, 750_000, 2048, 1_000_000_000_000, 1] +wasm_lanes = [ + [3, 750_000, 2048, 1_000_000_000_000, 1], + [4, 131_072, 1024, 100_000_000_000, 2], + [5, 65_536, 512, 5_000_000_000, 40] +] + +[transactions.deploy] +# The maximum number of Motes allowed to be spent during payment. 0 means unlimited. +max_payment_cost = '0' +# The limit of length of serialized payment code arguments. +payment_args_max_length = 1024 +# The limit of length of serialized session code arguments. +session_args_max_length = 1024 + +[wasm.v1] +# Amount of free memory (in 64kB pages) each contract can use for stack. +max_memory = 64 +# Max stack height (native WebAssembly stack limiter). +max_stack_height = 500 + +[storage_costs] +# Gas charged per byte stored in the global state. +gas_per_byte = 1_117_587 + +# For each opcode cost below there exists a static cost and a dynamic cost. +# The static cost is a fixed cost for each opcode that is hardcoded and validated by benchmarks. +[wasm.v1.opcode_costs] +# Bit operations multiplier. +bit = 105 +# Arithmetic add operations multiplier. +add = 105 +# Mul operations multiplier. +mul = 105 +# Div operations multiplier. +div = 105 +# Memory load operation multiplier. +load = 105 +# Memory store operation multiplier. +store = 105 +# Const store operation multiplier. +const = 105 +# Local operations multiplier. +local = 105 +# Global operations multiplier. +global = 105 +# Integer operations multiplier. +integer_comparison = 105 +# Conversion operations multiplier. +conversion = 105 +# Unreachable operation multiplier. +unreachable = 105 +# Nop operation multiplier. +nop = 105 +# Get current memory operation multiplier. +current_memory = 105 +# Grow memory cost, per page (64kb). +grow_memory = 900 +# Sign extension operations cost +sign = 105 + +# Control flow operations multiplier. +[wasm.v1.opcode_costs.control_flow] +block = 255 +loop = 255 +if = 105 +else = 105 +end = 105 +br = 1665 +br_if = 510 +return = 105 +select = 105 +call = 225 +call_indirect = 270 +drop = 105 + +[wasm.v1.opcode_costs.control_flow.br_table] +# Fixed cost per `br_table` opcode +cost = 150 +# Size of target labels in the `br_table` opcode will be multiplied by `size_multiplier` +size_multiplier = 100 + +# Host function declarations are located in smart_contracts/contract/src/ext_ffi.rs +[wasm.v1.host_function_costs] +add = { cost = 5_800, arguments = [0, 0, 0, 0] } +add_associated_key = { cost = 1_200_000, arguments = [0, 0, 0] } +add_contract_version = { cost = 200, arguments = [0, 0, 0, 0, 120_000, 0, 0, 0, 0, 0] } +add_contract_version_with_message_topics = { cost = 200, arguments = [0, 0, 0, 0, 120_000, 0, 0, 0, 30_000, 0, 0] } +add_package_version_with_message_topics = { cost = 200, arguments = [0, 0, 0, 0, 120_000, 0, 0, 0, 30_000, 0, 0] } +blake2b = { cost = 1_200_000, arguments = [0, 120_000, 0, 0] } +call_contract = { cost = 300_000_000, arguments = [0, 0, 0, 120_000, 0, 120_000, 0] } +call_versioned_contract = { cost = 300_000_000, arguments = [0, 0, 0, 0, 0, 120_000, 0, 120_000, 0] } +create_contract_package_at_hash = { cost = 200, arguments = [0, 0] } +create_contract_user_group = { cost = 200, arguments = [0, 0, 0, 0, 0, 0, 0, 0] } +create_purse = { cost = 2_500_000_000, arguments = [0, 0] } +disable_contract_version = { cost = 200, arguments = [0, 0, 0, 0] } +get_balance = { cost = 3_000_000, arguments = [0, 0, 0] } +get_blocktime = { cost = 330, arguments = [0] } +get_caller = { cost = 380, arguments = [0] } +get_key = { cost = 2_000, arguments = [0, 440, 0, 0, 0] } +get_main_purse = { cost = 1_300, arguments = [0] } +get_named_arg = { cost = 200, arguments = [0, 120_000, 0, 120_000] } +get_named_arg_size = { cost = 200, arguments = [0, 0, 0] } +get_phase = { cost = 710, arguments = [0] } +get_system_contract = { cost = 1_100, arguments = [0, 0, 0] } +has_key = { cost = 1_500, arguments = [0, 840] } +is_valid_uref = { cost = 760, arguments = [0, 0] } +load_named_keys = { cost = 42_000, arguments = [0, 0] } +new_uref = { cost = 17_000, arguments = [0, 0, 590] } +random_bytes = { cost = 200, arguments = [0, 0] } +print = { cost = 20_000, arguments = [0, 4_600] } +provision_contract_user_group_uref = { cost = 200, arguments = [0, 0, 0, 0, 0] } +put_key = { cost = 100_000_000, arguments = [0, 120_000, 0, 120_000] } +read_host_buffer = { cost = 3_500, arguments = [0, 310, 0] } +read_value = { cost = 60_000, arguments = [0, 120_000, 0] } +dictionary_get = { cost = 5_500, arguments = [0, 590, 0] } +remove_associated_key = { cost = 4_200, arguments = [0, 0] } +remove_contract_user_group = { cost = 200, arguments = [0, 0, 0, 0] } +remove_contract_user_group_urefs = { cost = 200, arguments = [0, 0, 0, 0, 0, 120_000] } +remove_key = { cost = 61_000, arguments = [0, 3_200] } +ret = { cost = 23_000, arguments = [0, 420_000] } +revert = { cost = 500, arguments = [0] } +set_action_threshold = { cost = 74_000, arguments = [0, 0] } +transfer_from_purse_to_account = { cost = 2_500_000_000, arguments = [0, 0, 0, 0, 0, 0, 0, 0, 0] } +transfer_from_purse_to_purse = { cost = 82_000_000, arguments = [0, 0, 0, 0, 0, 0, 0, 0] } +transfer_to_account = { cost = 2_500_000_000, arguments = [0, 0, 0, 0, 0, 0, 0] } +update_associated_key = { cost = 4_200, arguments = [0, 0, 0] } +write = { cost = 14_000, arguments = [0, 0, 0, 980] } +dictionary_put = { cost = 9_500, arguments = [0, 1_800, 0, 520] } +enable_contract_version = { cost = 200, arguments = [0, 0, 0, 0] } +manage_message_topic = { cost = 200, arguments = [0, 30_000, 0, 0] } +emit_message = { cost = 200, arguments = [0, 30_000, 0, 120_000] } +generic_hash = { cost = 1_200_000, arguments = [0, 120_000, 0, 0, 0] } +cost_increase_per_message = 50 +get_block_info = { cost = 330, arguments = [0, 0] } +recover_secp256k1 = { cost = 1_300_000, arguments = [0, 120_000, 0, 0, 0, 0] } +verify_signature = { cost = 1_300_000, arguments = [0, 120_000, 0, 0, 0, 0] } +call_package_version = { cost = 300_000_000, arguments = [0, 0, 0, 0, 0, 0, 0, 120_000, 0, 120_000, 0] } + +[wasm.v2] +# Amount of free memory each contract can use for stack. +max_memory = 64 + +[wasm.v2.opcode_costs] +# Bit operations multiplier. +bit = 105 +# Arithmetic add operations multiplier. +add = 105 +# Mul operations multiplier. +mul = 105 +# Div operations multiplier. +div = 105 +# Memory load operation multiplier. +load = 105 +# Memory store operation multiplier. +store = 105 +# Const store operation multiplier. +const = 105 +# Local operations multiplier. +local = 105 +# Global operations multiplier. +global = 105 +# Integer operations multiplier. +integer_comparison = 105 +# Conversion operations multiplier. +conversion = 105 +# Unreachable operation multiplier. +unreachable = 105 +# Nop operation multiplier. +nop = 105 +# Get current memory operation multiplier. +current_memory = 105 +# Grow memory cost, per page (64kb). +grow_memory = 900 +# Sign extension operations cost +sign = 105 + +# Control flow operations multiplier. +[wasm.v2.opcode_costs.control_flow] +block = 255 +loop = 255 +if = 105 +else = 105 +end = 105 +br = 1665 +br_if = 510 +return = 105 +select = 105 +call = 225 +call_indirect = 270 +drop = 105 + +[wasm.v2.opcode_costs.control_flow.br_table] +# Fixed cost per `br_table` opcode +cost = 150 +# Size of target labels in the `br_table` opcode will be multiplied by `size_multiplier` +size_multiplier = 100 + +[wasm.v2.host_function_costs] +read = { cost = 0, arguments = [0, 0, 0, 0, 0, 0] } +write = { cost = 0, arguments = [0, 0, 0, 0, 0] } +remove = { cost = 0, arguments = [0, 0, 0] } +copy_input = { cost = 0, arguments = [0, 0] } +ret = { cost = 0, arguments = [0, 0] } +create = { cost = 0, arguments = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] } +transfer = { cost = 0, arguments = [0, 0, 0] } +env_balance = { cost = 0, arguments = [0, 0, 0, 0] } +upgrade = { cost = 0, arguments = [0, 0, 0, 0, 0, 0] } +call = { cost = 0, arguments = [0, 0, 0, 0, 0, 0, 0, 0, 0] } +print = { cost = 0, arguments = [0, 0] } +emit = { cost = 0, arguments = [0, 0, 0, 0] } +env_info = { cost = 0, arguments = [0, 0] } + +[wasm.messages_limits] +max_topic_name_size = 256 +max_topics_per_contract = 128 +max_message_size = 1_024 + +[system_costs] +# Penalty charge for calling invalid entry point in a system contract. +no_such_entrypoint = 2_500_000_000 + +[system_costs.auction_costs] +get_era_validators = 2_500_000_000 +read_seigniorage_recipients = 5_000_000_000 +add_bid = 2_500_000_000 +withdraw_bid = 2_500_000_000 +delegate = 2_500_000_000 +undelegate = 2_500_000_000 +run_auction = 2_500_000_000 +slash = 2_500_000_000 +distribute = 2_500_000_000 +withdraw_delegator_reward = 5_000_000_000 +withdraw_validator_reward = 5_000_000_000 +read_era_id = 2_500_000_000 +activate_bid = 2_500_000_000 +redelegate = 2_500_000_000 +change_bid_public_key = 5_000_000_000 +add_reservations = 2_500_000_000 +cancel_reservations = 2_500_000_000 + +[system_costs.mint_costs] +mint = 2_500_000_000 +reduce_total_supply = 2_500_000_000 +create = 2_500_000_000 +balance = 100_000_000 +burn = 100_000_000 +transfer = 100_000_000 +read_base_round_reward = 2_500_000_000 +mint_into_existing_purse = 2_500_000_000 + +[system_costs.handle_payment_costs] +get_payment_purse = 10_000 +set_refund_purse = 10_000 +get_refund_purse = 10_000 +finalize_payment = 2_500_000_000 + +[system_costs.standard_payment_costs] +pay = 10_000 + +[vacancy] +# The cost of a transaction is based on a multiplier. This allows for economic disincentives for misuse of the network. +# +# The network starts with a current_gas_price of min_gas_price. +# +# Each block has multiple limits (bytes, transactions, transfers, gas, etc.) +# The utilization for a block is determined by the highest percentage utilization of each these limits. +# +# Ex: transfers limit is 650 and transactions limit is 20 (assume other limits are not a factor here) +# 19 transactons -> 19/20 or 95% +# 600 transfers -> 600/650 or 92.3% +# resulting block utilization is 95 +# +# The utilization for an era is the average of all block utilizations. At the switch block, the dynamic gas_price is +# adjusted with the following: +# +# If utilization was below the lower_threshold, current_gas_price is decremented by one if higher than min_gas_price. +# If utilization falls between the thresholds, current_gas_price is not changed. +# If utilization was above the upper_threshold, current_gas_price is incremented by one if lower than max_gas_price. +# +# The cost charged for the transaction is simply the gas_used * current_gas_price. +upper_threshold = 90 +lower_threshold = 50 +max_gas_price = 1 +min_gas_price = 1 + +[evm] +enabled = true +# EVM chain IDs use 0x435350NN ("CSP" + network namespace) as EIP-155 replay domains. +# Namespaces: mainnet=0x01, testnet=0x02, integration=0x04, devnet=0x05, local=0xFF. +# This chainspec uses namespace 0x01; decimal 1129533441. +chain_id = 1_129_533_441 +spec = 'prague' +block_gas_limit = 30_000_000 +# Base fee is denominated in motes per EVM gas. A standard 21,000-gas +# transfer costs 0.105 CSPR, and a full 30,000,000-gas block costs 150 CSPR. +base_fee = 5_000 +# Number of wei represented by one mote. EVM gas prices are denominated in wei, +# while Casper fee accounting is denominated in motes. +wei_per_mote = 1_000_000_000 diff --git a/resources/evm/config-example.toml b/resources/evm/config-example.toml new file mode 100644 index 0000000000..489d068903 --- /dev/null +++ b/resources/evm/config-example.toml @@ -0,0 +1,661 @@ +# ================================ +# Configuration options for a node +# ================================ +[node] + +# If set, use this hash as a trust anchor when joining an existing network. +#trusted_hash = 'HEX-FORMATTED BLOCK HASH' + +# Historical sync behavior for this node. Options are: +# 'ttl' (node will attempt to acquire all block data to comply with time to live enforcement) +# 'genesis' (node will attempt to acquire all block data back to genesis) +# 'nosync' (node will only acquire blocks moving forward) +# 'isolated' (node will initialize without peers and will not accept peers) +# 'completeblock' (node will acquire complete block and shutdown) +# note: the only two states allowed to switch to Validate reactor state are `genesis` and `ttl`. +# it is recommended for dedicated validator nodes to be in ttl mode to increase +# their ability to maintain maximal uptime...if a long-running genesis validator +# goes offline and comes back up while in genesis mode, it must backfill +# any gaps in its block awareness before resuming validation. +# +# it is recommended for reporting non-validator nodes to be in genesis mode to +# enable support for queries at any block height. +# +# it is recommended for non-validator working nodes (for dapp support, etc) to run in +# ttl or nosync mode (depending upon their specific data requirements). +# +# thus for instance a node backing a block explorer would prefer genesis mode, +# while a node backing a dapp interested in very recent activity would prefer to run in nosync mode, +# and a node backing a dapp interested in auction activity or tracking trends would prefer to run in ttl mode. +# note: as time goes on, the time to sync back to genesis takes progressively longer. +# note: ttl is a chainsepc configured behavior on a given network; consult the `max_ttl` chainspec setting +# (it is currently ~2 hours by default on production and production-like networks but subject to change). +# note: `nosync` is incompatible with validator behavior; a nosync node is prevented from participating +# in consensus / switching to validate mode. it is primarily for lightweight nodes that are +# only interested in recent activity. +# note: an isolated node will not connect to, sync with, or keep up with the network, but will respond to +# binary port, rest server, event server, and diagnostic port connections. +sync_handling = 'ttl' + +# Idle time after which the syncing process is considered stalled. +idle_tolerance = '20 minutes' + +# When the syncing process is considered stalled, it'll be retried up to `max_attempts` times. +max_attempts = 3 + +# Default delay for the control events that have no dedicated delay requirements. +control_logic_default_delay = '1 second' + +# Flag which forces the node to resync all the blocks. +force_resync = false + +# A timeout for the ShutdownForUpgrade state, after which the node will upgrade even if not all +# conditions are satisfied. +shutdown_for_upgrade_timeout = '2 minutes' + +# Maximum time a node will wait for an upgrade to commit. +upgrade_timeout = '30 seconds' + +# The node detects when it should do a controlled shutdown when it is in a detectably bad state +# in order to avoid potentially catastrophic uncontrolled crashes. Generally, a node should be +# allowed to shutdown, and if restarted that node will generally recover gracefully and resume +# normal operation. However, actively validating nodes have subjective state in memory that is +# lost on shutdown / restart and must be reacquired from other validating nodes on restart. +# If all validating nodes shutdown in the middle of an era, social consensus is required to restart +# the network. As a mitigation for that, the following config can be set to true on some validator +# nodes to cause nodes that are supposed to be validators in the current era to ignore controlled +# shutdown events and stay up. This allows them to act as sentinels for the consensus data for +# other restarting nodes. This config is inert on non-validating nodes. +prevent_validator_shutdown = false + +# If true, skips committing a protocol upgrade locally when this node's tip is the last +# block before the upgrade's activation point. Instead, the node will acquire the +# post-upgrade chain via the ordinary syncing process, as if it were catching up. +skip_protocol_upgrade = false + +# ================================= +# Configuration options for logging +# ================================= +[logging] + +# Output format. Possible values are 'text' or 'json'. +format = 'json' + +# Colored output. Has no effect if format = 'json'. +color = false + +# Abbreviate module names in text output. Has no effect if format = 'json'. +abbreviate_modules = false + + +# =================================== +# Configuration options for consensus +# =================================== +[consensus] + +# Path (absolute, or relative to this config.toml) to validator's secret key file used to sign +# consensus messages. +secret_key_path = '/etc/casper/validator_keys/secret_key.pem' + +# The maximum number of blocks by which execution is allowed to lag behind finalization. +# If it is more than that, consensus will pause, and resume once the executor has caught up. +max_execution_delay = 6 + + +# ======================================= +# Configuration options for Zug consensus +# ======================================= +[consensus.zug] + +# Request the latest protocol state from a random peer periodically, with this interval. +# '0 seconds' means it is disabled and we never request the protocol state from a peer. +sync_state_interval = '1 second' + +# Log inactive or faulty validators periodically, with this interval. +# '0 seconds' means it is disabled and we never print the log message. +log_participation_interval = '1 minute' + +# The minimal proposal timeout. Validators wait this long for a proposal to receive a quorum of +# echo messages, before they vote to make the round skippable and move on to the next proposer. +proposal_timeout = '5 seconds' + +# The additional proposal delay that is still considered fast enough, in percent. This should +# take into account variables like empty vs. full blocks, network traffic etc. +# E.g. if proposing a full block while under heavy load takes 50% longer than an empty one +# while idle this should be at least 50, meaning that the timeout is 50% longer than +# necessary for a quorum of recent proposals, approximately. +proposal_grace_period = 200 + +# The average number of rounds after which the proposal timeout adapts by a factor of 2. +# Note: It goes up faster than it goes down: it takes fewer rounds to double than to halve. +proposal_timeout_inertia = 10 + +# The maximum difference between validators' clocks we expect. Incoming proposals whose timestamp +# lies in the future by more than that are rejected. +clock_tolerance = '1 second' + + +# =========================================== +# Configuration options for Highway consensus +# =========================================== +[consensus.highway] + +# The duration for which incoming vertices with missing dependencies should be kept in a queue. +pending_vertex_timeout = '30 minutes' + +# Request the latest protocol state from a random peer periodically, with this interval. +# '0 seconds' means it is disabled and we never request the protocol state from a peer. +request_state_interval = '20 seconds' + +# Log inactive or faulty validators periodically, with this interval. +# '0 seconds' means it is disabled and we never print the log message. +log_participation_interval = '1 minute' + +# Log the synchronizer state periodically, with this interval. +# '0 seconds' means it is disabled and we never print the log message. +log_synchronizer_interval = '5 seconds' + +# Log the size of every incoming and outgoing serialized unit. +log_unit_sizes = false + +# The maximum number of peers we request the same vertex from in parallel. +max_requests_for_vertex = 5 + +# The maximum number of dependencies we request per validator in a batch. +# Limits requests per validator in panorama - in order to get a total number of +# requests, multiply by # of validators. +max_request_batch_size = 20 + +[consensus.highway.round_success_meter] +# The number of most recent rounds we will be keeping track of. +num_rounds_to_consider = 40 + +# The number of successful rounds that triggers us to slow down: With this many or fewer +# successes per `num_rounds_to_consider`, we increase our round length. +num_rounds_slowdown = 10 + +# The number of successful rounds that triggers us to speed up: With this many or more successes +# per `num_rounds_to_consider`, we decrease our round length. +num_rounds_speedup = 32 + +# We will try to accelerate (decrease our round length) every `acceleration_parameter` rounds if +# we have few enough failures. +acceleration_parameter = 40 + +# The FTT, as a percentage (i.e. `acceleration_ftt = [1, 100]` means 1% of the validators' total weight), which +# we will use for looking for a summit in order to determine a proposal's finality. +# The required quorum in a summit we will look for to check if a round was successful is +# determined by this FTT. +acceleration_ftt = [1, 100] + + +# ==================================== +# Configuration options for networking +# ==================================== +[network] + +# The public address of the node. +# +# It must be publicly available in order to allow peers to connect to this node. +# If the port is set to 0, the actual bound port will be substituted. +public_address = ':0' + +# Address to bind to for listening. +# If port is set to 0, a random port will be used. +bind_address = '0.0.0.0:35000' + +# Addresses to connect to in order to join the network. +# +# If not set, this node will not be able to attempt to connect to the network. Instead it will +# depend upon peers connecting to it. This is normally only useful for the first node of the +# network. +# +# Multiple addresses can be given and the node will attempt to connect to each, requiring at least +# one connection. +known_addresses = ['51.81.106.54:35000','135.148.34.108:35000','135.148.169.178:35000','51.83.238.2:35000','142.4.215.112:35000'] + +# Minimum number of fully-connected peers to consider network component initialized. +min_peers_for_initialization = 3 + +# The interval between each fresh round of gossiping the node's public address. +gossip_interval = '120 seconds' + +# Initial delay for starting address gossipping after the network starts. This should be slightly +# more than the expected time required for initial connections to complete. +initial_gossip_delay = '5 seconds' + +# How long a connection is allowed to be stuck as pending before it is abandoned. +max_addr_pending_time = '1 minute' + +# Maximum time allowed for a connection handshake between two nodes to be completed. Connections +# exceeding this threshold are considered unlikely to be healthy or even malicious and thus +# terminated. +handshake_timeout = '20 seconds' + +# Maximum number of incoming connections per unique peer allowed. If the limit is hit, additional +# connections will be rejected. A value of `0` means unlimited. +max_incoming_peer_connections = 3 + +# The maximum total of upstream bandwidth in bytes per second allocated to non-validating peers. +# A value of `0` means unlimited. +max_outgoing_byte_rate_non_validators = 6553600 + +# The maximum allowed total impact of requests from non-validating peers per second answered. +# A value of `0` means unlimited. +max_incoming_message_rate_non_validators = 3000 + +# Maximum number of requests for data from a single peer that are allowed be buffered. A value of +# `0` means unlimited. +max_in_flight_demands = 50 + +# Version threshold to enable tarpit for. +# +# When set to a version (the value may be `null` to disable the feature), any peer that reports a +# protocol version equal or below the threshold will be rejected only after holding open the +# connection for a specific (`tarpit_duration`) amount of time. +# +# This option makes most sense to enable on known nodes with addresses where legacy nodes that are +# still in operation are connecting to, as these older versions will only attempt to reconnect to +# other nodes once they have exhausted their set of known nodes. +tarpit_version_threshold = '1.2.1' + +# How long to hold connections to trapped legacy nodes. +tarpit_duration = '10 minutes' + +# The probability [0.0, 1.0] of this node trapping a legacy node. +# +# Since older nodes will only reconnect if all their options are exhausted, it is sufficient for a +# single known node to hold open a connection to prevent the node from reconnecting. This should be +# set to `1/n` or higher, with `n` being the number of known nodes expected in the configuration of +# legacy nodes running this software. +tarpit_chance = 0.2 + +# Minimum time a peer is kept on block list before being redeemed. The actual +# timeout duration is calculated by selecting a random value between +# . +blocklist_retain_min_duration = '2 minutes' + +# Maximum time a peer is kept on block list before being redeemed. The actual +# timeout duration is calculated by selecting a random value between +# . +blocklist_retain_max_duration = '10 minutes' + +# Identity of a node +# +# When this section is not specified, an identity will be generated when the node process starts with a self-signed certifcate. +# This option makes sense for some private chains where for security reasons joining new nodes is restricted. +# [network.identity] +# tls_certificate = "node_cert.pem" +# secret_key = "node.pem" +# ca_certificate = "ca_cert.pem" + +# Weights for impact estimation of incoming messages, used in combination with +# `max_incoming_message_rate_non_validators`. +# +# Any weight set to 0 means that the category of traffic is exempt from throttling. +[network.estimator_weights] +consensus = 0 +block_gossip = 1 +transaction_gossip = 0 +finality_signature_gossip = 1 +address_gossip = 0 +finality_signature_broadcasts = 0 +transaction_requests = 1 +transaction_responses = 0 +legacy_deploy_requests = 1 +legacy_deploy_responses = 0 +block_requests = 1 +block_responses = 0 +block_header_requests = 1 +block_header_responses = 0 +trie_requests = 1 +trie_responses = 0 +finality_signature_requests = 1 +finality_signature_responses = 0 +sync_leap_requests = 1 +sync_leap_responses = 0 +approvals_hashes_requests = 1 +approvals_hashes_responses = 0 +execution_results_requests = 1 +execution_results_responses = 0 + +# ================================================== +# Configuration options for the BinaryPort server +# ================================================== +[binary_port_server] + +# Flag which enables the BinaryPort server. +enable_server = true + +# Listening address for BinaryPort server. +address = '0.0.0.0:7779' + +# Flag that enables the `AllValues` get request. Disabled by default, because it can potentially be abused to retrieve huge amounts of data and clog the node. +allow_request_get_all_values = false + +# Flag that enables the `Trie` get request. Disabled by default, because it can potentially be abused to retrieve huge amounts of data and clog the node. +allow_request_get_trie = false + +# Flag that enables the `TrySpeculativeExec` request. Disabled by default. +allow_request_speculative_exec = true + +# Maximum size of a message in bytes. +max_message_size_bytes = 134_217_728 + +# Maximum number of connections to the server. +max_connections = 5 + +# The global max rate of requests (per second) before they are limited. +# The implementation uses a sliding window algorithm. +# TEMPORARY: Sidecar currently fans a single RPC request out into many binary-port +# requests. Keep this limit high until sidecar caching is improved. +qps_limit = 11_000 + +# Initial time given to a connection before it expires +initial_connection_lifetime = '10 seconds' + +#The amount of time which is given to a connection to extend it's lifetime when a valid +# [`Command::Get(GetRequest::Record)`] is sent to the node +get_record_request_termination_delay = '0 seconds' + +#The amount of time which is given to a connection to extend it's lifetime when a valid +#[`Command::Get(GetRequest::Information)`] is sent to the node +get_information_request_termination_delay = '5 seconds' + +#The amount of time which is given to a connection to extend it's lifetime when a valid +#[`Command::Get(GetRequest::State)`] is sent to the node +get_state_request_termination_delay = '0 seconds' + +#The amount of time which is given to a connection to extend it's lifetime when a valid +#[`Command::Get(GetRequest::Trie)`] is sent to the node +get_trie_request_termination_delay = '0 seconds' + +#The amount of time which is given to a connection to extend it's lifetime when a valid +#[`Command::TryAcceptTransaction`] is sent to the node +accept_transaction_request_termination_delay = '24 seconds' + +#The amount of time which is given to a connection to extend it's lifetime when a valid +#[`Command::TrySpeculativeExec`] is sent to the node +speculative_exec_request_termination_delay = '0 seconds' + +#The amount of time which is given to a connection to extend it's lifetime when a valid + +# ============================================== +# Configuration options for the REST HTTP server +# ============================================== +[rest_server] + +# Flag which enables the REST HTTP server. +enable_server = true + +# Listening address for REST HTTP server. If the port is set to 0, a random port will be used. +# +# If the specified port cannot be bound to, a random port will be tried instead. If binding fails, +# the REST HTTP server will not run, but the node will be otherwise unaffected. +# +# The actual bound address will be reported via a log line if logging is enabled. +address = '0.0.0.0:8888' + +# The global max rate of requests (per second) before they are limited. +# Request will be delayed to the next 1 second bucket once limited. +# TEMPORARY: Sidecar currently generates a high volume of REST requests. +# Keep this limit high until sidecar caching is improved. +qps_limit = 10_000 + +# Specifies which origin will be reported as allowed by REST server. +# +# If left empty, CORS will be disabled. +# If set to '*', any origin is allowed. +# Otherwise, only a specified origin is allowed. The given string must conform to the [origin scheme](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin). +cors_origin = '' + + +# ========================================================== +# Configuration options for the SSE HTTP event stream server +# ========================================================== +[event_stream_server] + +# Flag which enables the SSE HTTP event stream server. +enable_server = true + +# Listening address for SSE HTTP event stream server. If the port is set to 0, a random port will be used. +# +# If the specified port cannot be bound to, a random port will be tried instead. If binding fails, +# the SSE HTTP event stream server will not run, but the node will be otherwise unaffected. +# +# The actual bound address will be reported via a log line if logging is enabled. +address = '0.0.0.0:9999' + +# The number of event stream events to buffer. +event_stream_buffer_length = 5000 + +# The maximum number of subscribers across all event streams the server will permit at any one time. +max_concurrent_subscribers = 100 + +# Specifies which origin will be reported as allowed by event stream server. +# +# If left empty, CORS will be disabled. +# If set to '*', any origin is allowed. +# Otherwise, only a specified origin is allowed. The given string must conform to the [origin scheme](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin). +cors_origin = '' + +# =============================================== +# Configuration options for the storage component +# =============================================== +[storage] + +# Path (absolute, or relative to this config.toml) to the folder where any files created +# or read by the storage component will exist. A subfolder named with the network name will be +# automatically created and used for the storage component files. +# +# If the folder doesn't exist, it and any required parents will be created. +# +# If unset, the path must be supplied as an argument via the CLI. +path = '/var/lib/casper/casper-node' + +# Maximum size of the database to use for the block store. +# +# The size should be a multiple of the OS page size. +# +# 483_183_820_800 == 450 GiB. +max_block_store_size = 483_183_820_800 + +# Maximum size of the database to use for the deploy store. +# +# The size should be a multiple of the OS page size. +# +# 322_122_547_200 == 300 GiB. +max_deploy_store_size = 322_122_547_200 + +# Maximum size of the database to use for the deploy metadata. +# +# The size should be a multiple of the OS page size. +# +# 322_122_547_200 == 300 GiB. +max_deploy_metadata_store_size = 322_122_547_200 + +# Maximum size of the database to use for the state snapshots. +# +# The size should be a multiple of the OS page size. +# +# 10_737_418_240 == 10 GiB. +max_state_store_size = 10_737_418_240 + +# Memory deduplication. +# +# If enabled, nodes will attempt to share loaded objects if possible. +enable_mem_deduplication = true + +# Memory duplication garbage collection. +# +# Sets the frequency how often the memory pool cache is swept for free references. +# For example, setting this value to 5 means that every 5th time something is put in the pool the cache is swept. +mem_pool_prune_interval = 4096 + + +# =================================== +# Configuration options for gossiping +# =================================== +[gossip] + +# Target number of peers to infect with a given piece of data. +infection_target = 3 + +# The saturation limit as a percentage, with a maximum value of 99. Used as a termination +# condition. +# +# Example: assume the `infection_target` is 3, the `saturation_limit_percent` is 80, and we don't +# manage to newly infect 3 peers. We will stop gossiping once we know of more than 15 holders +# excluding us since 80% saturation would imply 3 new infections in 15 peers. +saturation_limit_percent = 80 + +# The maximum duration for which to keep finished entries. +# +# The longer they are retained, the lower the likelihood of re-gossiping a piece of data. However, +# the longer they are retained, the larger the list of finished entries can grow. +finished_entry_duration = '1 minute' + +# The timeout duration for a single gossip request, i.e. for a single gossip message +# sent from this node, it will be considered timed out if the expected response from that peer is +# not received within this specified duration. +gossip_request_timeout = '30 seconds' + +# The timeout duration for retrieving the remaining part(s) of newly-discovered data +# from a peer which gossiped information about that data to this node. +get_remainder_timeout = '5 seconds' + +# The timeout duration for a newly-received, gossiped item to be validated and stored by another +# component before the gossiper abandons waiting to gossip the item onwards. +validate_and_store_timeout = '1 minute' + + +# =============================================== +# Configuration options for the block accumulator +# =============================================== +[block_accumulator] + +# Block height difference threshold for starting to execute the blocks. +attempt_execution_threshold = 6 + +# Accepted time interval for inactivity in block accumulator. +dead_air_interval = '3 minutes' + +# Time after which the block acceptors are considered old and can be purged. +purge_interval = '1 minute' + + +# ================================================ +# Configuration options for the block synchronizer +# ================================================ +[block_synchronizer] + +# Maximum number of fetch-trie tasks to run in parallel during block synchronization. +max_parallel_trie_fetches = 5000 + +# Time interval for the node to ask for refreshed peers. +peer_refresh_interval = '90 seconds' + +# Time interval for the node to check what the block synchronizer needs to acquire next. +need_next_interval = '1 second' + +# Time interval for recurring disconnection of dishonest peers. +disconnect_dishonest_peers_interval = '10 seconds' + +# Time interval for resetting the latch in block builders. +latch_reset_interval = '5 seconds' + + +# ============================================= +# Configuration options for the block validator +# ============================================= +[block_validator] + +# Maximum number of completed entries to retain. +# +# A higher value can avoid creating needless validation work on an already-validated proposed +# block, but comes at the cost of increased memory consumption. +max_completed_entries = 6 + + +# ================================== +# Configuration options for fetchers +# ================================== +[fetcher] + +# The timeout duration for a single fetcher request, i.e. for a single fetcher message +# sent from this node to another node, it will be considered timed out if the expected response from that peer is +# not received within this specified duration. +get_from_peer_timeout = '10 seconds' + + +# ======================================================== +# Configuration options for the contract runtime component +# ======================================================== +[contract_runtime] + +# Optional maximum size of the database to use for the global state store. +# +# If unset, defaults to 805,306,368,000 == 750 GiB. +# +# The size should be a multiple of the OS page size. +max_global_state_size = 2_089_072_132_096 + +# Optional depth limit to use for global state queries. +# +# If unset, defaults to 5. +#max_query_depth = 5 + +# Enable manual synchronizing to disk. +# +# If unset, defaults to true. +#enable_manual_sync = true + + +# ================================================== +# Configuration options for the transaction acceptor +# ================================================== +[transaction_acceptor] + +# The leeway allowed when considering whether a transaction is future-dated or not. +# +# To accommodate minor clock drift, transactions whose timestamps are within `timestamp_leeway` in the +# future are still acceptable. +# +# The maximum value to which `timestamp_leeway` can be set is defined by the chainspec setting +# `transaction.max_timestamp_leeway`. +timestamp_leeway = '2 seconds' + + +# =========================================== +# Configuration options for the transaction buffer +# =========================================== +[transaction_buffer] + +# The interval of checking for expired transactions. +expiry_check_interval = '1 minute' + + +# ============================================== +# Configuration options for the diagnostics port +# ============================================== +[diagnostics_port] + +# If set, the diagnostics port will be available on a UNIX socket. +enabled = false + +# Filename for the UNIX domain socket the diagnostics port listens on. +socket_path = "debug.socket" + +# The umask to set before creating the socket. A restrictive mask like `0o077` will cause the +# socket to be only accessible by the user the node runs as. A more relaxed variant is `0o007`, +# which allows for group access as well. +socket_umask = 0o077 + + +# ============================================= +# Configuration options for the upgrade watcher +# ============================================= +[upgrade_watcher] + +# How often to scan file system for available upgrades. +upgrade_check_interval = '30 seconds' From 18f591d1961d0453c574d6b74098bf2a04d10d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Fri, 18 Sep 2026 12:08:09 +0200 Subject: [PATCH 18/19] Update SSE schema for EVM error messages --- resources/test/sse_data_schema.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/resources/test/sse_data_schema.json b/resources/test/sse_data_schema.json index fec4b08f07..b6cb26be16 100644 --- a/resources/test/sse_data_schema.json +++ b/resources/test/sse_data_schema.json @@ -5413,6 +5413,13 @@ } ] }, + "error_message": { + "description": "If present, the transaction failed to fully process for the stated reason.", + "type": [ + "string", + "null" + ] + }, "current_price": { "description": "The current Casper gas price used for fee accounting.", "type": "integer", From 0291ce9fe0bdf211bc436b89a0f189a22d322225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Papierski?= Date: Mon, 21 Sep 2026 12:59:16 +0200 Subject: [PATCH 19/19] Move InvalidTransaction variant to end of Error enum --- executor/evm/src/error.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/executor/evm/src/error.rs b/executor/evm/src/error.rs index 23095373dc..869b82715a 100644 --- a/executor/evm/src/error.rs +++ b/executor/evm/src/error.rs @@ -34,15 +34,15 @@ pub enum Error { /// Failed to translate revm transaction environment. #[error("failed to build EVM transaction environment: {0}")] Transaction(String), - /// revm rejected the transaction during pre-execution validation. - #[error("EVM transaction validation failed: {0}")] - InvalidTransaction(#[source] EvmTransactionError), /// revm rejected execution before producing state. #[error("EVM execution failed: {0}")] Revm(String), /// Failed to apply EVM state changes to the tracking copy. #[error("failed to apply EVM state changes: {0}")] State(String), + /// revm rejected the transaction during pre-execution validation. + #[error("EVM transaction validation failed: {0}")] + InvalidTransaction(#[source] EvmTransactionError), } /// Errors emitted by the revm database adapter.