diff --git a/Cargo.lock b/Cargo.lock index 1b40159cf9..626fc2b956 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2776,7 +2776,7 @@ dependencies = [ [[package]] name = "ethereum" version = "0.18.2" -source = "git+https://github.com/moonbeam-foundation/ethereum?branch=moonbeam-polkadot-stable2506#cf3076f07e61102eec686f6816da668f97d94f1f" +source = "git+https://github.com/moonbeam-foundation/ethereum?branch=moonbeam-polkadot-stable2506#301236b0cbbbd38dda2fadd68658e9a26e5c7e7a" dependencies = [ "bytes", "ethereum-types", diff --git a/Cargo.toml b/Cargo.toml index 358d0788db..d1f5775619 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ clap = { version = "4.5", features = ["derive", "deprecated"] } const-hex = { version = "1.14", default-features = false, features = ["alloc"] } derive_more = "1.0" environmental = { version = "1.1.4", default-features = false } +# Backport note (stable2506): use moonbeam ethereum fork for tx-size helpers used by RPC size guards. ethereum = { git = "https://github.com/moonbeam-foundation/ethereum", branch = "moonbeam-polkadot-stable2506", default-features = false } ethereum-types = { version = "0.15", default-features = false } evm = { git = "https://github.com/moonbeam-foundation/evm", branch = "moonbeam-polkadot-stable2506", default-features = false } diff --git a/client/rpc-core/src/types/mod.rs b/client/rpc-core/src/types/mod.rs index f824cb1143..74981378f2 100644 --- a/client/rpc-core/src/types/mod.rs +++ b/client/rpc-core/src/types/mod.rs @@ -63,7 +63,9 @@ pub use self::{ Peers, PipProtocolInfo, SyncInfo, SyncStatus, TransactionStats, }, transaction::{LocalTransactionStatus, RichRawTransaction, Transaction}, - transaction_request::{TransactionMessage, TransactionRequest}, + transaction_request::{ + TransactionMessage, TransactionRequest, DEFAULT_MAX_TX_INPUT_BYTES, TX_SLOT_BYTE_SIZE, + }, work::Work, }; diff --git a/client/rpc-core/src/types/transaction_request.rs b/client/rpc-core/src/types/transaction_request.rs index 4eea1cdb01..ec29595cc4 100644 --- a/client/rpc-core/src/types/transaction_request.rs +++ b/client/rpc-core/src/types/transaction_request.rs @@ -25,6 +25,21 @@ use serde::{Deserialize, Deserializer}; use crate::types::Bytes; +/// The default byte size of a transaction slot (32 KiB). +/// +/// Reference: +/// - geth: (`txSlotSize`) +/// - reth: +pub const TX_SLOT_BYTE_SIZE: usize = 32 * 1024; + +/// The default maximum size a single transaction can have (128 KiB). +/// This is the RLP-encoded size of the signed transaction. +/// +/// Reference: +/// - geth: (`txMaxSize`) +/// - reth: +pub const DEFAULT_MAX_TX_INPUT_BYTES: usize = 4 * TX_SLOT_BYTE_SIZE; + /// Transaction request from the RPC. #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize)] #[serde(rename_all = "camelCase")] @@ -174,6 +189,75 @@ impl TransactionRequest { fn chain_id_u64(&self) -> u64 { self.chain_id.map(|id| id.as_u64()).unwrap_or_default() } + + /// Estimates signed transaction size for DoS protection. + /// + /// This is an estimate based on message RLP length plus signature overhead. It is used as + /// a lightweight RPC pre-check, not as an exact post-signing boundary check. + /// We convert the request to its transaction message type and use `encoded_len()`. + /// + /// Reference: + /// - geth: (`tx.Size()`) + /// - reth: (`PoolTransaction::encoded_length()`) + /// - alloy: (`rlp_encoded_fields_length()`) + pub fn encoded_length(&self) -> usize { + // Convert to transaction message and use the ethereum crate's encoded_len() + let message: Option = self.clone().into(); + + match message { + Some(TransactionMessage::Legacy(msg)) => { + // Legacy: RLP([nonce, gasPrice, gasLimit, to, value, data, v, r, s]) + // v is variable (27/28 or chainId*2+35/36), r and s are 32 bytes each + msg.encoded_len() + Self::SIGNATURE_RLP_OVERHEAD + } + Some(TransactionMessage::EIP2930(msg)) => { + // EIP-2930: 0x01 || RLP([chainId, nonce, gasPrice, gasLimit, to, value, data, accessList, yParity, r, s]) + 1 + msg.encoded_len() + Self::SIGNATURE_RLP_OVERHEAD + } + Some(TransactionMessage::EIP1559(msg)) => { + // EIP-1559: 0x02 || RLP([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, yParity, r, s]) + 1 + msg.encoded_len() + Self::SIGNATURE_RLP_OVERHEAD + } + Some(TransactionMessage::EIP7702(msg)) => { + // EIP-7702: 0x04 || RLP([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, authorizationList, yParity, r, s]) + 1 + msg.encoded_len() + Self::SIGNATURE_RLP_OVERHEAD + } + None => { + // Fallback for invalid/incomplete requests - use conservative estimate + // This shouldn't happen in normal operation as validation should catch it + Self::DEFAULT_FALLBACK_SIZE + } + } + } + + /// RLP overhead for signature fields (yParity + r + s) + /// - yParity: 1 byte (0x00 or 0x01 encoded as single byte) + /// - r: typically 33 bytes (0x80 + 32 bytes, or less if leading zeros) + /// - s: typically 33 bytes (0x80 + 32 bytes, or less if leading zeros) + const SIGNATURE_RLP_OVERHEAD: usize = 1 + 33 + 33; + + /// Fallback size for invalid requests that can't be converted to a message + const DEFAULT_FALLBACK_SIZE: usize = 256; + + /// Validates that the estimated transaction size is within limits. + /// + /// This prevents DoS attacks via clearly oversized transactions before they enter the pool. + /// The limit matches geth's `txMaxSize` and reth's `DEFAULT_MAX_TX_INPUT_BYTES`. + /// + /// Reference: + /// - geth: (`ValidateTransaction`) + /// - reth: + pub fn validate_size(&self) -> Result<(), String> { + let size = self.encoded_length(); + + if size > DEFAULT_MAX_TX_INPUT_BYTES { + return Err(format!( + "oversized data: transaction size {} exceeds limit {}", + size, DEFAULT_MAX_TX_INPUT_BYTES + )); + } + Ok(()) + } } /// Additional data of the transaction. @@ -446,4 +530,145 @@ mod tests { } ); } + + #[test] + fn test_request_size_validation_large_access_list() { + use ethereum::AccessListItem; + use ethereum_types::{H160, H256}; + + // Create access list that exceeds 128KB (131,072 bytes) + // Each storage key RLP-encodes to ~33 bytes + // 4000 keys * 33 bytes = 132,000 bytes > 128KB + let storage_keys: Vec = (0..4000).map(|_| H256::default()).collect(); + let access_list = vec![AccessListItem { + address: H160::default(), + storage_keys, + }]; + let request = TransactionRequest { + access_list: Some(access_list), + ..Default::default() + }; + assert!(request.validate_size().is_err()); + } + + #[test] + fn test_request_size_validation_valid() { + use ethereum::AccessListItem; + use ethereum_types::{H160, H256}; + + // 100 storage keys is well under 128KB + let request = TransactionRequest { + access_list: Some(vec![AccessListItem { + address: H160::default(), + storage_keys: vec![H256::default(); 100], + }]), + ..Default::default() + }; + assert!(request.validate_size().is_ok()); + } + + #[test] + fn test_encoded_length_includes_signature_overhead() { + // A minimal EIP-1559 transaction should include signature overhead + // Default TransactionRequest converts to EIP-1559 (no gas_price, no access_list) + let request = TransactionRequest::default(); + let size = request.encoded_length(); + + // EIP-1559 message RLP: ~11 bytes for minimal fields (all zeros/empty) + // + 1 byte type prefix + 67 bytes signature overhead = ~79 bytes minimum + // The signature overhead (67 bytes) is the key verification + assert!( + size >= TransactionRequest::SIGNATURE_RLP_OVERHEAD, + "Size {} should be at least signature overhead {}", + size, + TransactionRequest::SIGNATURE_RLP_OVERHEAD + ); + + // Verify it's a reasonable size for a minimal transaction + assert!( + size < 200, + "Size {} should be reasonable for minimal tx", + size + ); + } + + #[test] + fn test_encoded_length_typed_transaction_overhead() { + use ethereum::AccessListItem; + use ethereum_types::H160; + + // EIP-1559 transaction (has max_fee_per_gas) + let request = TransactionRequest { + max_fee_per_gas: Some(U256::from(1000)), + access_list: Some(vec![AccessListItem { + address: H160::default(), + storage_keys: vec![], + }]), + ..Default::default() + }; + let typed_size = request.encoded_length(); + + // Legacy transaction + let legacy_request = TransactionRequest { + gas_price: Some(U256::from(1000)), + ..Default::default() + }; + let legacy_size = legacy_request.encoded_length(); + + // Typed transaction should be larger due to: + // - Type byte (+1) + // - Chain ID (+9) + // - max_priority_fee_per_gas (+33) + // - Access list overhead + assert!( + typed_size > legacy_size, + "Typed tx {} should be larger than legacy {}", + typed_size, + legacy_size + ); + } + + #[test] + fn test_encoded_length_access_list_scaling() { + use ethereum::AccessListItem; + use ethereum_types::{H160, H256}; + + // Transaction with 10 storage keys + let request_10 = TransactionRequest { + access_list: Some(vec![AccessListItem { + address: H160::default(), + storage_keys: vec![H256::default(); 10], + }]), + ..Default::default() + }; + + // Transaction with 100 storage keys + let request_100 = TransactionRequest { + access_list: Some(vec![AccessListItem { + address: H160::default(), + storage_keys: vec![H256::default(); 100], + }]), + ..Default::default() + }; + + let size_10 = request_10.encoded_length(); + let size_100 = request_100.encoded_length(); + + // Size should scale roughly linearly with storage keys + // 90 additional keys * ~34 bytes each ≈ 3060 bytes difference + let diff = size_100 - size_10; + assert!( + diff > 2500 && diff < 4000, + "Size difference {} should be proportional to storage keys", + diff + ); + } + + #[test] + fn test_constants_match_geth_reth() { + // Verify our constants match geth/reth exactly + assert_eq!(TX_SLOT_BYTE_SIZE, 32 * 1024); // 32 KiB + assert_eq!(DEFAULT_MAX_TX_INPUT_BYTES, 128 * 1024); // 128 KiB + assert_eq!(DEFAULT_MAX_TX_INPUT_BYTES, 4 * TX_SLOT_BYTE_SIZE); + } } diff --git a/client/rpc/src/eth/submit.rs b/client/rpc/src/eth/submit.rs index 0a575ac299..b216e42bff 100644 --- a/client/rpc/src/eth/submit.rs +++ b/client/rpc/src/eth/submit.rs @@ -29,7 +29,7 @@ use sp_core::H160; use sp_inherents::CreateInherentDataProviders; use sp_runtime::{traits::Block as BlockT, transaction_validity::TransactionSource}; // Frontier -use fc_rpc_core::types::*; +use fc_rpc_core::types::{TransactionRequest, DEFAULT_MAX_TX_INPUT_BYTES, *}; use fp_rpc::{ConvertTransaction, ConvertTransactionRuntimeApi, EthereumRuntimeRPCApi}; use crate::{ @@ -78,6 +78,12 @@ where } pub async fn send_transaction(&self, request: TransactionRequest) -> RpcResult { + // Policy: this is an estimate-based pre-check to block clearly oversized requests. + // Exact signed-size boundary parity is intentionally not required here. + request + .validate_size() + .map_err(|msg| crate::err(jsonrpsee::types::error::INVALID_PARAMS_CODE, &msg, None))?; + let from = match request.from { Some(from) => from, None => { @@ -190,6 +196,23 @@ where return Err(internal_err("transaction data is empty")); } + // Validate transaction size to prevent DoS attacks. + // This matches geth/reth pool validation which rejects transactions > 128 KB. + // Reference: + // - geth: https://github.com/ethereum/go-ethereum/blob/master/core/txpool/validation.go + // - reth: https://github.com/paradigmxyz/reth/blob/main/crates/transaction-pool/src/validate/eth.rs#L342-L363 + if bytes.len() > DEFAULT_MAX_TX_INPUT_BYTES { + return Err(crate::err( + jsonrpsee::types::error::INVALID_PARAMS_CODE, + &format!( + "oversized data: transaction size {} exceeds limit {}", + bytes.len(), + DEFAULT_MAX_TX_INPUT_BYTES + ), + None, + )); + } + let transaction: ethereum::TransactionV3 = match ethereum::EnvelopedDecodable::decode(&bytes) { Ok(transaction) => transaction, diff --git a/frame/evm/src/lib.rs b/frame/evm/src/lib.rs index ed8110c4a9..9fda88737d 100644 --- a/frame/evm/src/lib.rs +++ b/frame/evm/src/lib.rs @@ -708,7 +708,7 @@ pub mod pallet { const ZERO_ACCOUNT: H160 = H160::zero(); // just a dummy read to populate the pov with the intermediates nodes - let _ = AccountCodesMetadata::::get(ZERO_ACCOUNT.clone()); + let _ = AccountCodesMetadata::::get(ZERO_ACCOUNT); let (_, min_gas_weight) = T::FeeCalculator::min_gas_price(); let (_, account_basic_weight) = Pallet::::account_basic(&ZERO_ACCOUNT); diff --git a/primitives/evm/src/lib.rs b/primitives/evm/src/lib.rs index 4f4ecd14f6..804347913e 100644 --- a/primitives/evm/src/lib.rs +++ b/primitives/evm/src/lib.rs @@ -192,11 +192,9 @@ impl WeightInfo { } pub fn remaining_proof_size(&self) -> Option { - if let Some(proof_size_limit) = self.proof_size_limit { - Some(proof_size_limit.saturating_sub(self.proof_size_usage.unwrap_or_default())) - } else { - None - } + self.proof_size_limit.map(|proof_size_limit| { + proof_size_limit.saturating_sub(self.proof_size_usage.unwrap_or_default()) + }) } pub fn remaining_ref_time(&self) -> Option {