Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
213 changes: 213 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,62 @@ 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.
///
/// Returns an error if the transaction request cannot be converted to a valid message type.
pub fn encoded_length(&self) -> Result<usize, String> {
// 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
Ok(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])
Ok(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])
Ok(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])
Ok(1 + msg.encoded_len() + Self::SIGNATURE_RLP_OVERHEAD)
}
None => Err(
"invalid transaction parameters: unable to determine transaction type".to_string(),
),
}
}

/// 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;

/// 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 +509,154 @@ 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().unwrap();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please never use unwrap in production code, the error should be propagated instead

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not even in tests?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In tests it's ok but I prefer expect even in tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a5c026f


// 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().unwrap();

// Legacy transaction
let legacy_request = TransactionRequest {
gas_price: Some(U256::from(1000)),
..Default::default()
};
let legacy_size = legacy_request.encoded_length().unwrap();

// 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().unwrap();
let size_100 = request_100.encoded_length().unwrap();

// 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);
}

#[test]
fn test_encoded_length_invalid_parameters() {
// Transaction with both max_fee_per_gas and gas_price set (invalid combination)
let request = TransactionRequest {
max_fee_per_gas: Some(U256::from(1000)),
gas_price: Some(U256::from(500)),
..Default::default()
};

assert!(request.encoded_length().is_err());
assert!(request.validate_size().is_err());
}
}
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