//! Validation of [`NewPooledTransactionHashes66`](reth_eth_wire::NewPooledTransactionHashes66) //! and [`NewPooledTransactionHashes68`](reth_eth_wire::NewPooledTransactionHashes68) //! announcements. Validation and filtering of announcements is network dependent. use std::{fmt, fmt::Display, mem}; use crate::metrics::{AnnouncedTxTypesMetrics, TxTypesCounter}; use derive_more::{Deref, DerefMut}; use reth_eth_wire::{ DedupPayload, Eth68TxMetadata, HandleMempoolData, PartiallyValidData, ValidAnnouncementData, MAX_MESSAGE_SIZE, }; use reth_primitives::{Signature, TxHash, TxType}; use tracing::trace; /// The size of a decoded signature in bytes. pub const SIGNATURE_DECODED_SIZE_BYTES: usize = mem::size_of::(); /// Interface for validating a `(ty, size, hash)` tuple from a /// [`NewPooledTransactionHashes68`](reth_eth_wire::NewPooledTransactionHashes68).. pub trait ValidateTx68 { /// Validates a [`NewPooledTransactionHashes68`](reth_eth_wire::NewPooledTransactionHashes68) /// entry. Returns [`ValidationOutcome`] which signals to the caller whether to fetch the /// transaction or to drop it, and whether the sender of the announcement should be /// penalized. fn should_fetch( &self, ty: u8, hash: &TxHash, size: usize, tx_types_counter: &mut TxTypesCounter, ) -> ValidationOutcome; /// Returns the reasonable maximum encoded transaction length configured for this network, if /// any. This property is not spec'ed out but can be inferred by looking how much data can be /// packed into a transaction for any given transaction type. fn max_encoded_tx_length(&self, ty: TxType) -> Option; /// Returns the strict maximum encoded transaction length for the given transaction type, if /// any. fn strict_max_encoded_tx_length(&self, ty: TxType) -> Option; /// Returns the reasonable minimum encoded transaction length, if any. This property is not /// spec'ed out but can be inferred by looking at which /// [`reth_primitives::PooledTransactionsElement`] will successfully pass decoding /// for any given transaction type. fn min_encoded_tx_length(&self, ty: TxType) -> Option; /// Returns the strict minimum encoded transaction length for the given transaction type, if /// any. fn strict_min_encoded_tx_length(&self, ty: TxType) -> Option; } /// Outcomes from validating a `(ty, hash, size)` entry from a /// [`NewPooledTransactionHashes68`](reth_eth_wire::NewPooledTransactionHashes68). Signals to the /// caller how to deal with an announcement entry and the peer who sent the announcement. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ValidationOutcome { /// Tells the caller to keep the entry in the announcement for fetch. Fetch, /// Tells the caller to filter out the entry from the announcement. Ignore, /// Tells the caller to filter out the entry from the announcement and penalize the peer. On /// this outcome, caller can drop the announcement, that is up to each implementation. ReportPeer, } /// Generic filter for announcements and responses. Checks for empty message and unique hashes/ /// transactions in message. pub trait PartiallyFilterMessage { /// Removes duplicate entries from a mempool message. Returns [`FilterOutcome::ReportPeer`] if /// the caller should penalize the peer, otherwise [`FilterOutcome::Ok`]. fn partially_filter_valid_entries( &self, msg: impl DedupPayload + fmt::Debug, ) -> (FilterOutcome, PartiallyValidData) { // 1. checks if the announcement is empty if msg.is_empty() { trace!(target: "net::tx", msg=?msg, "empty payload" ); return (FilterOutcome::ReportPeer, PartiallyValidData::empty_eth66()) } // 2. checks if announcement is spam packed with duplicate hashes let original_len = msg.len(); let partially_valid_data = msg.dedup(); ( if partially_valid_data.len() != original_len { FilterOutcome::ReportPeer } else { FilterOutcome::Ok }, partially_valid_data, ) } } /// Filters valid entries in /// [`NewPooledTransactionHashes68`](reth_eth_wire::NewPooledTransactionHashes68) and /// [`NewPooledTransactionHashes66`](reth_eth_wire::NewPooledTransactionHashes66) in place, and /// flags misbehaving peers. pub trait FilterAnnouncement { /// Removes invalid entries from a /// [`NewPooledTransactionHashes68`](reth_eth_wire::NewPooledTransactionHashes68) announcement. /// Returns [`FilterOutcome::ReportPeer`] if the caller should penalize the peer, otherwise /// [`FilterOutcome::Ok`]. fn filter_valid_entries_68( &self, msg: PartiallyValidData, ) -> (FilterOutcome, ValidAnnouncementData) where Self: ValidateTx68; /// Removes invalid entries from a /// [`NewPooledTransactionHashes66`](reth_eth_wire::NewPooledTransactionHashes66) announcement. /// Returns [`FilterOutcome::ReportPeer`] if the caller should penalize the peer, otherwise /// [`FilterOutcome::Ok`]. fn filter_valid_entries_66( &self, msg: PartiallyValidData, ) -> (FilterOutcome, ValidAnnouncementData); } /// Outcome from filtering /// [`NewPooledTransactionHashes68`](reth_eth_wire::NewPooledTransactionHashes68). Signals to caller /// whether to penalize the sender of the announcement or not. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FilterOutcome { /// Peer behaves appropriately. Ok, /// A penalty should be flagged for the peer. Peer sent an announcement with unacceptably /// invalid entries. ReportPeer, } /// Wrapper for types that implement [`FilterAnnouncement`]. The definition of a valid /// announcement is network dependent. For example, different networks support different /// [`TxType`]s, and different [`TxType`]s have different transaction size constraints. Defaults to /// [`EthMessageFilter`]. #[derive(Debug, Default, Deref, DerefMut)] pub struct MessageFilter(N); /// Filter for announcements containing EIP [`TxType`]s. #[derive(Debug, Default)] pub struct EthMessageFilter { announced_tx_types_metrics: AnnouncedTxTypesMetrics, } impl Display for EthMessageFilter { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "EthMessageFilter") } } impl PartiallyFilterMessage for EthMessageFilter {} impl ValidateTx68 for EthMessageFilter { fn should_fetch( &self, ty: u8, hash: &TxHash, size: usize, tx_types_counter: &mut TxTypesCounter, ) -> ValidationOutcome { // // 1. checks if tx type is valid value for this network // let tx_type = match TxType::try_from(ty) { Ok(ty) => ty, Err(_) => { trace!(target: "net::eth-wire", ty=ty, size=size, hash=%hash, network=%self, "invalid tx type in eth68 announcement" ); return ValidationOutcome::ReportPeer } }; tx_types_counter.increase_by_tx_type(tx_type); // // 2. checks if tx's encoded length is within limits for this network // // transactions that are filtered out here, may not be spam, rather from benevolent peers // that are unknowingly sending announcements with invalid data. // if let Some(strict_min_encoded_tx_length) = self.strict_min_encoded_tx_length(tx_type) { if size < strict_min_encoded_tx_length { trace!(target: "net::eth-wire", ty=ty, size=size, hash=%hash, strict_min_encoded_tx_length=strict_min_encoded_tx_length, network=%self, "invalid tx size in eth68 announcement" ); return ValidationOutcome::Ignore } } if let Some(reasonable_min_encoded_tx_length) = self.min_encoded_tx_length(tx_type) { if size < reasonable_min_encoded_tx_length { trace!(target: "net::eth-wire", ty=ty, size=size, hash=%hash, reasonable_min_encoded_tx_length=reasonable_min_encoded_tx_length, strict_min_encoded_tx_length=self.strict_min_encoded_tx_length(tx_type), network=%self, "tx size in eth68 announcement, is unreasonably small" ); // just log a tx which is smaller than the reasonable min encoded tx length, don't // filter it out } } // this network has no strict max encoded tx length for any tx type if let Some(reasonable_max_encoded_tx_length) = self.max_encoded_tx_length(tx_type) { if size > reasonable_max_encoded_tx_length { trace!(target: "net::eth-wire", ty=ty, size=size, hash=%hash, reasonable_max_encoded_tx_length=reasonable_max_encoded_tx_length, strict_max_encoded_tx_length=self.strict_max_encoded_tx_length(tx_type), network=%self, "tx size in eth68 announcement, is unreasonably large" ); // just log a tx which is bigger than the reasonable max encoded tx length, don't // filter it out } } ValidationOutcome::Fetch } fn max_encoded_tx_length(&self, ty: TxType) -> Option { // the biggest transaction so far is a blob transaction, which is currently max 2^17, // encoded length, nonetheless, the blob tx may become bigger in the future. #[allow(unreachable_patterns, clippy::match_same_arms)] match ty { TxType::Legacy | TxType::Eip2930 | TxType::Eip1559 => Some(MAX_MESSAGE_SIZE), TxType::Eip4844 => None, _ => None, } } fn strict_max_encoded_tx_length(&self, _ty: TxType) -> Option { None } fn min_encoded_tx_length(&self, _ty: TxType) -> Option { // a transaction will have at least a signature. the encoded signature encoded on the tx // is at least as big as the decoded type. Some(SIGNATURE_DECODED_SIZE_BYTES) } fn strict_min_encoded_tx_length(&self, _ty: TxType) -> Option { // decoding a tx will exit right away if it's not at least a byte Some(1) } } impl FilterAnnouncement for EthMessageFilter { fn filter_valid_entries_68( &self, mut msg: PartiallyValidData, ) -> (FilterOutcome, ValidAnnouncementData) where Self: ValidateTx68, { trace!(target: "net::tx::validation", msg=?*msg, network=%self, "validating eth68 announcement data.." ); let mut should_report_peer = false; let mut tx_types_counter = TxTypesCounter::default(); // checks if eth68 announcement metadata is valid // // transactions that are filtered out here, may not be spam, rather from benevolent peers // that are unknowingly sending announcements with invalid data. // msg.retain(|hash, metadata| { debug_assert!( metadata.is_some(), "metadata should exist for `%hash` in eth68 announcement passed to `%filter_valid_entries_68`, `%hash`: {hash}" ); let Some((ty, size)) = metadata else { return false }; match self.should_fetch(*ty, hash, *size, &mut tx_types_counter) { ValidationOutcome::Fetch => true, ValidationOutcome::Ignore => false, ValidationOutcome::ReportPeer => { should_report_peer = true; false } } }); self.announced_tx_types_metrics.update_eth68_announcement_metrics(tx_types_counter); ( if should_report_peer { FilterOutcome::ReportPeer } else { FilterOutcome::Ok }, ValidAnnouncementData::from_partially_valid_data(msg), ) } fn filter_valid_entries_66( &self, partially_valid_data: PartiallyValidData>, ) -> (FilterOutcome, ValidAnnouncementData) { trace!(target: "net::tx::validation", hashes=?*partially_valid_data, network=%self, "validating eth66 announcement data.." ); (FilterOutcome::Ok, ValidAnnouncementData::from_partially_valid_data(partially_valid_data)) } } // TODO(eip7702): update tests as needed #[cfg(test)] mod test { use super::*; use reth_eth_wire::{NewPooledTransactionHashes66, NewPooledTransactionHashes68}; use reth_primitives::B256; use std::{collections::HashMap, str::FromStr}; #[test] fn eth68_empty_announcement() { let types = vec![]; let sizes = vec![]; let hashes = vec![]; let announcement = NewPooledTransactionHashes68 { types, sizes, hashes }; let filter = EthMessageFilter::default(); let (outcome, _partially_valid_data) = filter.partially_filter_valid_entries(announcement); assert_eq!(outcome, FilterOutcome::ReportPeer); } #[test] fn eth68_announcement_unrecognized_tx_type() { let types = vec![ TxType::MAX_RESERVED_EIP as u8 + 1, // the first type isn't valid TxType::Legacy as u8, ]; let sizes = vec![MAX_MESSAGE_SIZE, MAX_MESSAGE_SIZE]; let hashes = vec![ B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafa") .unwrap(), B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefbbbb") .unwrap(), ]; let announcement = NewPooledTransactionHashes68 { types: types.clone(), sizes: sizes.clone(), hashes: hashes.clone(), }; let filter = EthMessageFilter::default(); let (outcome, partially_valid_data) = filter.partially_filter_valid_entries(announcement); assert_eq!(outcome, FilterOutcome::Ok); let (outcome, valid_data) = filter.filter_valid_entries_68(partially_valid_data); assert_eq!(outcome, FilterOutcome::ReportPeer); let mut expected_data = HashMap::new(); expected_data.insert(hashes[1], Some((types[1], sizes[1]))); assert_eq!(expected_data, valid_data.into_data()) } #[test] fn eth68_announcement_too_small_tx() { let types = vec![TxType::MAX_RESERVED_EIP as u8, TxType::Legacy as u8, TxType::Eip2930 as u8]; let sizes = vec![ 0, // the first length isn't valid 0, // neither is the second 1, ]; let hashes = vec![ B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafa") .unwrap(), B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefbbbb") .unwrap(), B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeef00bb") .unwrap(), ]; let announcement = NewPooledTransactionHashes68 { types: types.clone(), sizes: sizes.clone(), hashes: hashes.clone(), }; let filter = EthMessageFilter::default(); let (outcome, partially_valid_data) = filter.partially_filter_valid_entries(announcement); assert_eq!(outcome, FilterOutcome::Ok); let (outcome, valid_data) = filter.filter_valid_entries_68(partially_valid_data); assert_eq!(outcome, FilterOutcome::Ok); let mut expected_data = HashMap::new(); expected_data.insert(hashes[2], Some((types[2], sizes[2]))); assert_eq!(expected_data, valid_data.into_data()) } #[test] fn eth68_announcement_duplicate_tx_hash() { let types = vec![ TxType::Eip1559 as u8, TxType::Eip4844 as u8, TxType::Eip1559 as u8, TxType::Eip4844 as u8, ]; let sizes = vec![1, 1, 1, MAX_MESSAGE_SIZE]; // first three or the same let hashes = vec![ B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafa") // dup .unwrap(), B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafa") // removed dup .unwrap(), B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafa") // removed dup .unwrap(), B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefbbbb") .unwrap(), ]; let announcement = NewPooledTransactionHashes68 { types: types.clone(), sizes: sizes.clone(), hashes: hashes.clone(), }; let filter = EthMessageFilter::default(); let (outcome, partially_valid_data) = filter.partially_filter_valid_entries(announcement); assert_eq!(outcome, FilterOutcome::ReportPeer); let mut expected_data = HashMap::new(); expected_data.insert(hashes[3], Some((types[3], sizes[3]))); expected_data.insert(hashes[0], Some((types[0], sizes[0]))); assert_eq!(expected_data, partially_valid_data.into_data()) } #[test] fn eth66_empty_announcement() { let hashes = vec![]; let announcement = NewPooledTransactionHashes66(hashes); let filter: MessageFilter = MessageFilter::default(); let (outcome, _partially_valid_data) = filter.partially_filter_valid_entries(announcement); assert_eq!(outcome, FilterOutcome::ReportPeer); } #[test] fn eth66_announcement_duplicate_tx_hash() { // first three or the same let hashes = vec![ B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefbbbb") // dup1 .unwrap(), B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafa") // dup2 .unwrap(), B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafa") // removed dup2 .unwrap(), B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafa") // removed dup2 .unwrap(), B256::from_str("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefbbbb") // removed dup1 .unwrap(), ]; let announcement = NewPooledTransactionHashes66(hashes.clone()); let filter: MessageFilter = MessageFilter::default(); let (outcome, partially_valid_data) = filter.partially_filter_valid_entries(announcement); assert_eq!(outcome, FilterOutcome::ReportPeer); let mut expected_data = HashMap::new(); expected_data.insert(hashes[1], None); expected_data.insert(hashes[0], None); assert_eq!(expected_data, partially_valid_data.into_data()) } #[test] fn test_display_for_zst() { let filter = EthMessageFilter::default(); assert_eq!("EthMessageFilter", &filter.to_string()); } }