Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
4 changes: 3 additions & 1 deletion client/rpc-core/src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
225 changes: 225 additions & 0 deletions client/rpc-core/src/types/transaction_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ use serde::{Deserialize, Deserializer};

use crate::types::Bytes;

/// The default byte size of a transaction slot (32 KiB).
///
/// Reference:
/// - geth: <https://github.com/ethereum/go-ethereum/blob/master/core/txpool/legacypool/legacypool.go> (`txSlotSize`)
/// - reth: <https://github.com/paradigmxyz/reth/blob/main/crates/transaction-pool/src/validate/constants.rs#L4>
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: <https://github.com/ethereum/go-ethereum/blob/master/core/txpool/legacypool/legacypool.go> (`txMaxSize`)
/// - reth: <https://github.com/paradigmxyz/reth/blob/main/crates/transaction-pool/src/validate/constants.rs#L11>
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")]
Expand Down Expand Up @@ -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: <https://github.com/ethereum/go-ethereum/blob/master/core/types/transaction.go> (`tx.Size()`)
/// - reth: <https://github.com/paradigmxyz/reth/blob/main/crates/transaction-pool/src/traits.rs> (`PoolTransaction::encoded_length()`)
/// - alloy: <https://github.com/alloy-rs/alloy/blob/main/crates/consensus/src/transaction/eip1559.rs> (`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<TransactionMessage> = 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: <https://github.com/ethereum/go-ethereum/blob/master/core/txpool/validation.go> (`ValidateTransaction`)
/// - reth: <https://github.com/paradigmxyz/reth/blob/main/crates/transaction-pool/src/validate/eth.rs#L342-L363>
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.
Expand Down Expand Up @@ -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<H256> = (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);
}
}
25 changes: 24 additions & 1 deletion client/rpc/src/eth/submit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -78,6 +78,12 @@ where
}

pub async fn send_transaction(&self, request: TransactionRequest) -> RpcResult<H256> {
// 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 => {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion frame/evm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<T>::get(ZERO_ACCOUNT.clone());
let _ = AccountCodesMetadata::<T>::get(ZERO_ACCOUNT);
let (_, min_gas_weight) = T::FeeCalculator::min_gas_price();
let (_, account_basic_weight) = Pallet::<T>::account_basic(&ZERO_ACCOUNT);

Expand Down
8 changes: 3 additions & 5 deletions primitives/evm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,9 @@ impl WeightInfo {
}

pub fn remaining_proof_size(&self) -> Option<u64> {
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<u64> {
Expand Down
Loading