Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
3 changes: 1 addition & 2 deletions Cargo.lock

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

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ precompile-utils = { path = "precompiles", default-features = false }
# Frontier Template
frontier-template-runtime = { path = "template/runtime", default-features = false }

# TODO: Remove this patch before merging once ethereum 0.19.0 is released
[patch.crates-io]
ethereum = { git = "https://github.com/rust-ethereum/ethereum.git", rev = "5185fe8efa62add7e989660a69c6945c8a3ae64e" }

[profile.release]
# Substrate runtime requires unwinding.
panic = "unwind"
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
203 changes: 203 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,13 @@ use serde::{Deserialize, Deserializer};

use crate::types::Bytes;

/// The default byte size of a transaction slot (32 KiB).
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.
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 +181,65 @@ impl TransactionRequest {
fn chain_id_u64(&self) -> u64 {
self.chain_id.map(|id| id.as_u64()).unwrap_or_default()
}

/// Calculates the RLP-encoded size of the signed transaction for DoS protection.
///
/// This mirrors geth's `tx.Size()` and reth's `transaction.encoded_length()` which use
/// actual RLP encoding to determine transaction size. We convert the request to its
/// transaction message type and use the `encoded_len()` method from the ethereum crate.
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 signed transaction size is within limits.
///
/// This prevents DoS attacks via oversized transactions before they enter the pool.
/// The limit matches geth's `txMaxSize` and reth's `DEFAULT_MAX_TX_INPUT_BYTES`.
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 {size} exceeds limit {DEFAULT_MAX_TX_INPUT_BYTES}"
));
}
Ok(())
}
}

/// Additional data of the transaction.
Expand Down Expand Up @@ -446,4 +512,141 @@ 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 {size} should be reasonable for minimal tx"
);
}

#[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 {typed_size} should be larger than legacy {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 {diff} should be proportional to storage keys"
);
}

#[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);
}
}
19 changes: 18 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 All @@ -49,6 +49,10 @@ where
CIDP: CreateInherentDataProviders<B, ()> + Send + 'static,
{
pub async fn send_transaction(&self, request: TransactionRequest) -> RpcResult<H256> {
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 @@ -160,6 +164,19 @@ 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.
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 {DEFAULT_MAX_TX_INPUT_BYTES}",
bytes.len()
),
None,
));
}

let transaction: ethereum::TransactionV3 =
match ethereum::EnvelopedDecodable::decode(&bytes) {
Ok(transaction) => transaction,
Expand Down
Loading