Skip to main content

reth_transaction_pool/
error.rs

1//! Transaction pool errors
2
3use std::any::Any;
4
5use alloy_eips::eip4844::BlobTransactionValidationError;
6use alloy_primitives::{Address, TxHash, U256};
7use reth_primitives_traits::transaction::error::InvalidTransactionError;
8
9/// Transaction pool result type.
10pub type PoolResult<T> = Result<T, PoolError>;
11
12/// Errors that can happen while recovering a raw transaction into a pool transaction.
13#[derive(Debug, thiserror::Error)]
14pub enum RawPoolTransactionError {
15    /// The raw transaction data is empty.
16    #[error("empty transaction data")]
17    EmptyRawTransactionData,
18    /// Decoding the signed transaction failed.
19    #[error("failed to decode signed transaction")]
20    FailedToDecodeSignedTransaction,
21    /// The transaction signature is invalid.
22    #[error("invalid transaction signature")]
23    InvalidTransactionSignature,
24    /// Any other error that occurred while recovering the raw pool transaction.
25    #[error(transparent)]
26    Other(#[from] Box<dyn core::error::Error + Send + Sync>),
27}
28
29impl RawPoolTransactionError {
30    /// Creates a new [`RawPoolTransactionError::Other`] variant.
31    pub fn other(error: impl Into<Box<dyn core::error::Error + Send + Sync>>) -> Self {
32        Self::Other(error.into())
33    }
34}
35
36/// A trait for additional errors that can be thrown by the transaction pool.
37///
38/// For example during validation
39/// [`TransactionValidator::validate_transaction`](crate::validate::TransactionValidator::validate_transaction)
40pub trait PoolTransactionError: core::error::Error + Send + Sync {
41    /// Returns `true` if the error was caused by a transaction that is considered bad in the
42    /// context of the transaction pool and warrants peer penalization.
43    ///
44    /// See [`PoolError::is_bad_transaction`].
45    fn is_bad_transaction(&self) -> bool;
46
47    /// Returns a reference to `self` as a `&dyn Any`, enabling downcasting.
48    fn as_any(&self) -> &dyn Any;
49}
50
51// Needed for `#[error(transparent)]`
52impl core::error::Error for Box<dyn PoolTransactionError> {
53    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
54        (**self).source()
55    }
56}
57
58/// Transaction pool error.
59#[derive(Debug, thiserror::Error)]
60#[error("[{hash}]: {kind}")]
61pub struct PoolError {
62    /// The transaction hash that caused the error.
63    pub hash: TxHash,
64    /// The error kind.
65    pub kind: PoolErrorKind,
66}
67
68/// Transaction pool error kind.
69#[derive(Debug, thiserror::Error)]
70pub enum PoolErrorKind {
71    /// Same transaction already imported
72    #[error("already imported")]
73    AlreadyImported,
74    /// Thrown if a replacement transaction's gas price is below the already imported transaction
75    #[error("insufficient gas price to replace existing transaction")]
76    ReplacementUnderpriced,
77    /// The fee cap of the transaction is below the minimum fee cap determined by the protocol
78    #[error("transaction feeCap {0} below chain minimum")]
79    FeeCapBelowMinimumProtocolFeeCap(u128),
80    /// Thrown when the number of unique transactions of a sender exceeded the slot capacity.
81    #[error("rejected due to {0} being identified as a spammer")]
82    SpammerExceededCapacity(Address),
83    /// Thrown when a new transaction is added to the pool, but then immediately discarded to
84    /// respect the size limits of the pool.
85    #[error("transaction discarded outright due to pool size constraints")]
86    DiscardedOnInsert,
87    /// Thrown when the transaction is considered invalid.
88    #[error(transparent)]
89    InvalidTransaction(#[from] InvalidPoolTransactionError),
90    /// Thrown if the mutual exclusivity constraint (blob vs normal transaction) is violated.
91    #[error("transaction type {1} conflicts with existing transaction for {0}")]
92    ExistingConflictingTransactionType(Address, u8),
93    /// Any other error that occurred while inserting/validating a transaction. e.g. IO database
94    /// error
95    #[error(transparent)]
96    Other(#[from] Box<dyn core::error::Error + Send + Sync>),
97}
98
99// === impl PoolError ===
100
101impl PoolError {
102    /// Creates a new pool error.
103    pub fn new(hash: TxHash, kind: impl Into<PoolErrorKind>) -> Self {
104        Self { hash, kind: kind.into() }
105    }
106
107    /// Creates a new pool error with the `Other` kind.
108    pub fn other(
109        hash: TxHash,
110        error: impl Into<Box<dyn core::error::Error + Send + Sync>>,
111    ) -> Self {
112        Self { hash, kind: PoolErrorKind::Other(error.into()) }
113    }
114
115    /// Returns `true` if the error was caused by a transaction that is considered bad in the
116    /// context of the transaction pool and warrants peer penalization.
117    ///
118    /// Not all error variants are caused by the incorrect composition of the transaction (See also
119    /// [`InvalidPoolTransactionError`]) and can be caused by the current state of the transaction
120    /// pool. For example the transaction pool is already full or the error was caused by an
121    /// internal error, such as database errors.
122    ///
123    /// This function returns true only if the transaction will never make it into the pool because
124    /// its composition is invalid and the original sender should have detected this as well. This
125    /// is used to determine whether the original sender should be penalized for sending an
126    /// erroneous transaction.
127    #[inline]
128    pub fn is_bad_transaction(&self) -> bool {
129        #[expect(clippy::match_same_arms)]
130        match &self.kind {
131            PoolErrorKind::AlreadyImported => {
132                // already imported but not bad
133                false
134            }
135            PoolErrorKind::ReplacementUnderpriced => {
136                // already imported but not bad
137                false
138            }
139            PoolErrorKind::FeeCapBelowMinimumProtocolFeeCap(_) => {
140                // fee cap of the tx below the technical minimum determined by the protocol, see
141                // [MINIMUM_PROTOCOL_FEE_CAP](alloy_primitives::constants::MIN_PROTOCOL_BASE_FEE)
142                // although this transaction will always be invalid, we do not want to penalize the
143                // sender because this check simply could not be implemented by the client
144                false
145            }
146            PoolErrorKind::SpammerExceededCapacity(_) => {
147                // the sender exceeded the slot capacity, we should not penalize the peer for
148                // sending the tx because we don't know if all the transactions are sent from the
149                // same peer, there's also a chance that old transactions haven't been cleared yet
150                // (pool lags behind) and old transaction still occupy a slot in the pool
151                false
152            }
153            PoolErrorKind::DiscardedOnInsert => {
154                // valid tx but dropped due to size constraints
155                false
156            }
157            PoolErrorKind::InvalidTransaction(err) => {
158                // transaction rejected because it violates constraints
159                err.is_bad_transaction()
160            }
161            PoolErrorKind::Other(_) => {
162                // internal error unrelated to the transaction
163                false
164            }
165            PoolErrorKind::ExistingConflictingTransactionType(_, _) => {
166                // this is not a protocol error but an implementation error since the pool enforces
167                // exclusivity (blob vs normal tx) for all senders
168                false
169            }
170        }
171    }
172
173    /// Returns `true` if this is a blob sidecar error that should NOT be cached as a bad import.
174    ///
175    /// The transaction hash may be valid — the issue is peer-specific (e.g. malformed sidecar
176    /// data), so we penalize the peer but allow re-fetching from other peers.
177    #[inline]
178    pub const fn is_bad_blob_sidecar(&self) -> bool {
179        match &self.kind {
180            PoolErrorKind::InvalidTransaction(err) => err.is_bad_blob_sidecar(),
181            _ => false,
182        }
183    }
184}
185
186/// Represents all errors that can happen when validating transactions for the pool for EIP-4844
187/// transactions
188#[derive(Debug, thiserror::Error)]
189pub enum Eip4844PoolTransactionError {
190    /// Thrown if we're unable to find the blob for a transaction that was previously extracted
191    #[error("blob sidecar not found for EIP4844 transaction")]
192    MissingEip4844BlobSidecar,
193    /// Thrown if an EIP-4844 transaction without any blobs arrives
194    #[error("blobless blob transaction")]
195    NoEip4844Blobs,
196    /// Thrown if an EIP-4844 transaction arrives with too many blobs
197    #[error("too many blobs in transaction: have {have}, permitted {permitted}")]
198    TooManyEip4844Blobs {
199        /// Number of blobs the transaction has
200        have: u64,
201        /// Number of maximum blobs the transaction can have
202        permitted: u64,
203    },
204    /// Thrown if validating the blob sidecar for the transaction failed.
205    #[error(transparent)]
206    InvalidEip4844Blob(BlobTransactionValidationError),
207    /// EIP-4844 transactions are only accepted if they're gapless, meaning the previous nonce of
208    /// the transaction (`tx.nonce -1`) must either be in the pool or match the on chain nonce of
209    /// the sender.
210    ///
211    /// This error is thrown on validation if a valid blob transaction arrives with a nonce that
212    /// would introduce gap in the nonce sequence.
213    #[error("nonce too high")]
214    Eip4844NonceGap,
215    /// Thrown if blob transaction has an EIP-7594 style sidecar before Osaka.
216    #[error("unexpected eip-7594 sidecar before osaka")]
217    UnexpectedEip7594SidecarBeforeOsaka,
218    /// Thrown if blob transaction has an EIP-4844 style sidecar after Osaka.
219    #[error("unexpected eip-4844 sidecar after osaka")]
220    UnexpectedEip4844SidecarAfterOsaka,
221    /// Thrown if blob transaction has an EIP-7594 style sidecar but EIP-7594 support is disabled.
222    #[error("eip-7594 sidecar disallowed")]
223    Eip7594SidecarDisallowed,
224}
225
226/// Represents all errors that can happen when validating transactions for the pool for EIP-7702
227/// transactions
228#[derive(Debug, thiserror::Error)]
229pub enum Eip7702PoolTransactionError {
230    /// Thrown if the transaction has no items in its authorization list
231    #[error("no items in authorization list for EIP7702 transaction")]
232    MissingEip7702AuthorizationList,
233    /// Returned when a transaction with a nonce
234    /// gap is received from accounts with a deployed delegation or pending delegation.
235    #[error("gapped-nonce tx from delegated accounts")]
236    OutOfOrderTxFromDelegated,
237    /// Returned when the maximum number of in-flight
238    /// transactions is reached for specific accounts.
239    #[error("in-flight transaction limit reached for delegated accounts")]
240    InflightTxLimitReached,
241    /// Returned if a transaction has an authorization
242    /// signed by an address which already has in-flight transactions known to the
243    /// pool.
244    #[error("authority already reserved")]
245    AuthorityReserved,
246}
247
248/// Represents errors that can happen when validating transactions for the pool
249///
250/// See [`TransactionValidator`](crate::TransactionValidator).
251#[derive(Debug, thiserror::Error)]
252pub enum InvalidPoolTransactionError {
253    /// Hard consensus errors
254    #[error(transparent)]
255    Consensus(#[from] InvalidTransactionError),
256    /// Thrown when a new transaction is added to the pool, but then immediately discarded to
257    /// respect the size limits of the pool.
258    #[error("transaction's gas limit {0} exceeds block's gas limit {1}")]
259    ExceedsGasLimit(u64, u64),
260    /// Thrown when a transaction's gas limit exceeds the configured maximum per-transaction limit.
261    #[error("transaction's gas limit {0} exceeds maximum per-transaction gas limit {1}")]
262    MaxTxGasLimitExceeded(u64, u64),
263    /// Thrown when a new transaction is added to the pool, but then immediately discarded to
264    /// respect the tx fee exceeds the configured cap
265    #[error("tx fee ({max_tx_fee_wei} wei) exceeds the configured cap ({tx_fee_cap_wei} wei)")]
266    ExceedsFeeCap {
267        /// max fee in wei of new tx submitted to the pool (e.g. 0.11534 ETH)
268        max_tx_fee_wei: u128,
269        /// configured tx fee cap in wei (e.g. 1.0 ETH)
270        tx_fee_cap_wei: u128,
271    },
272    /// Thrown when a new transaction is added to the pool, but then immediately discarded to
273    /// respect the `max_init_code_size`.
274    #[error("transaction's input size {0} exceeds max_init_code_size {1}")]
275    ExceedsMaxInitCodeSize(usize, usize),
276    /// Thrown if the input data of a transaction is greater
277    /// than some meaningful limit a user might use. This is not a consensus error
278    /// making the transaction invalid, rather a DOS protection.
279    #[error("oversized data: transaction size {size}, limit {limit}")]
280    OversizedData {
281        /// Size of the transaction/input data that exceeded the limit.
282        size: usize,
283        /// Configured limit that was exceeded.
284        limit: usize,
285    },
286    /// Thrown if the transaction's fee is below the minimum fee
287    #[error("transaction underpriced")]
288    Underpriced,
289    /// Thrown if the transaction's would require an account to be overdrawn
290    #[error("transaction overdraws from account, balance: {balance}, cost: {cost}")]
291    Overdraft {
292        /// Cost transaction is allowed to consume. See `reth_transaction_pool::PoolTransaction`.
293        cost: U256,
294        /// Balance of account.
295        balance: U256,
296    },
297    /// EIP-2681 error thrown if the nonce is higher or equal than `U64::max`
298    /// `<https://eips.ethereum.org/EIPS/eip-2681>`
299    #[error("nonce exceeds u64 limit")]
300    Eip2681,
301    /// EIP-4844 related errors
302    #[error(transparent)]
303    Eip4844(#[from] Eip4844PoolTransactionError),
304    /// EIP-7702 related errors
305    #[error(transparent)]
306    Eip7702(#[from] Eip7702PoolTransactionError),
307    /// Any other error that occurred while inserting/validating that is transaction specific
308    #[error(transparent)]
309    Other(Box<dyn PoolTransactionError>),
310    /// The transaction is specified to use less gas than required to start the
311    /// invocation.
312    #[error("intrinsic gas too low")]
313    IntrinsicGasTooLow,
314    /// The transaction priority fee is below the minimum required priority fee.
315    #[error("transaction priority fee below minimum required priority fee {minimum_priority_fee}")]
316    PriorityFeeBelowMinimum {
317        /// Minimum required priority fee.
318        minimum_priority_fee: u128,
319    },
320}
321
322// === impl InvalidPoolTransactionError ===
323
324impl InvalidPoolTransactionError {
325    /// Returns a new [`InvalidPoolTransactionError::Other`] instance with the given
326    /// [`PoolTransactionError`].
327    pub fn other<E: PoolTransactionError + 'static>(err: E) -> Self {
328        Self::Other(Box::new(err))
329    }
330
331    /// Returns `true` if the error was caused by a transaction that is considered bad in the
332    /// context of the transaction pool and warrants peer penalization.
333    ///
334    /// See [`PoolError::is_bad_transaction`].
335    #[expect(clippy::match_same_arms)]
336    #[inline]
337    fn is_bad_transaction(&self) -> bool {
338        match self {
339            Self::Consensus(err) => {
340                // transaction considered invalid by the consensus rules
341                // We do not consider the following errors to be erroneous transactions, since they
342                // depend on dynamic environmental conditions and should not be assumed to have been
343                // intentionally caused by the sender
344                match err {
345                    InvalidTransactionError::InsufficientFunds { .. } |
346                    InvalidTransactionError::NonceNotConsistent { .. } => {
347                        // transaction could just have arrived late/early
348                        false
349                    }
350                    InvalidTransactionError::GasTooLow |
351                    InvalidTransactionError::GasTooHigh |
352                    InvalidTransactionError::TipAboveFeeCap => {
353                        // these are technically not invalid
354                        false
355                    }
356                    InvalidTransactionError::FeeCapTooLow => {
357                        // dynamic, but not used during validation
358                        false
359                    }
360                    InvalidTransactionError::Eip2930Disabled |
361                    InvalidTransactionError::Eip1559Disabled |
362                    InvalidTransactionError::Eip4844Disabled |
363                    InvalidTransactionError::Eip7702Disabled => {
364                        // settings
365                        false
366                    }
367                    InvalidTransactionError::OldLegacyChainId |
368                    InvalidTransactionError::ChainIdMismatch |
369                    InvalidTransactionError::GasUintOverflow |
370                    InvalidTransactionError::TxTypeNotSupported |
371                    InvalidTransactionError::SignerAccountHasBytecode |
372                    InvalidTransactionError::GasLimitTooHigh => true,
373                }
374            }
375            Self::ExceedsGasLimit(_, _) => true,
376            Self::MaxTxGasLimitExceeded(_, _) => {
377                // local setting
378                false
379            }
380            Self::ExceedsFeeCap { max_tx_fee_wei: _, tx_fee_cap_wei: _ } => {
381                // local setting
382                false
383            }
384            Self::ExceedsMaxInitCodeSize(_, _) => true,
385            Self::OversizedData { .. } => {
386                // local setting
387                false
388            }
389            Self::Underpriced => {
390                // local setting
391                false
392            }
393            Self::IntrinsicGasTooLow => true,
394            Self::Overdraft { .. } => false,
395            Self::Other(err) => err.is_bad_transaction(),
396            Self::Eip2681 => true,
397            Self::Eip4844(eip4844_err) => {
398                match eip4844_err {
399                    Eip4844PoolTransactionError::MissingEip4844BlobSidecar => {
400                        // this is only reachable when blob transactions are reinjected and we're
401                        // unable to find the previously extracted blob
402                        false
403                    }
404                    Eip4844PoolTransactionError::InvalidEip4844Blob(_) => {
405                        // This is only reachable when the blob is invalid
406                        true
407                    }
408                    Eip4844PoolTransactionError::Eip4844NonceGap => {
409                        // it is possible that the pool sees `nonce n` before `nonce n-1` and this
410                        // is only thrown for valid(good) blob transactions
411                        false
412                    }
413                    Eip4844PoolTransactionError::NoEip4844Blobs => {
414                        // this is a malformed transaction and should not be sent over the network
415                        true
416                    }
417                    Eip4844PoolTransactionError::TooManyEip4844Blobs { .. } => {
418                        // this is a malformed transaction and should not be sent over the network
419                        true
420                    }
421                    Eip4844PoolTransactionError::UnexpectedEip4844SidecarAfterOsaka |
422                    Eip4844PoolTransactionError::UnexpectedEip7594SidecarBeforeOsaka |
423                    Eip4844PoolTransactionError::Eip7594SidecarDisallowed => {
424                        // for now we do not want to penalize peers for broadcasting different
425                        // sidecars
426                        false
427                    }
428                }
429            }
430            Self::Eip7702(eip7702_err) => match eip7702_err {
431                Eip7702PoolTransactionError::MissingEip7702AuthorizationList => {
432                    // as EIP-7702 specifies, 7702 transactions must have an non-empty authorization
433                    // list so this is a malformed transaction and should not be
434                    // sent over the network
435                    true
436                }
437                Eip7702PoolTransactionError::OutOfOrderTxFromDelegated => false,
438                Eip7702PoolTransactionError::InflightTxLimitReached => false,
439                Eip7702PoolTransactionError::AuthorityReserved => false,
440            },
441            Self::PriorityFeeBelowMinimum { .. } => false,
442        }
443    }
444
445    /// Returns `true` if this is a blob sidecar error (e.g. invalid proof, missing sidecar).
446    ///
447    /// These errors indicate the sidecar data from a specific peer was bad, but the transaction
448    /// hash itself may be valid when fetched from another peer.
449    #[inline]
450    pub const fn is_bad_blob_sidecar(&self) -> bool {
451        matches!(
452            self,
453            Self::Eip4844(
454                Eip4844PoolTransactionError::MissingEip4844BlobSidecar |
455                    Eip4844PoolTransactionError::InvalidEip4844Blob(_) |
456                    Eip4844PoolTransactionError::UnexpectedEip7594SidecarBeforeOsaka |
457                    Eip4844PoolTransactionError::UnexpectedEip4844SidecarAfterOsaka |
458                    Eip4844PoolTransactionError::Eip7594SidecarDisallowed
459            )
460        )
461    }
462
463    /// Returns true if this is a [`Self::Consensus`] variant.
464    pub const fn as_consensus(&self) -> Option<&InvalidTransactionError> {
465        match self {
466            Self::Consensus(err) => Some(err),
467            _ => None,
468        }
469    }
470
471    /// Returns true if this is [`InvalidTransactionError::NonceNotConsistent`] and the
472    /// transaction's nonce is lower than the state's.
473    pub fn is_nonce_too_low(&self) -> bool {
474        match self {
475            Self::Consensus(err) => err.is_nonce_too_low(),
476            _ => false,
477        }
478    }
479
480    /// Returns `true` if an import failed due to an oversized transaction
481    pub const fn is_oversized(&self) -> bool {
482        matches!(self, Self::OversizedData { .. })
483    }
484
485    /// Returns `true` if an import failed due to nonce gap.
486    pub const fn is_nonce_gap(&self) -> bool {
487        matches!(self, Self::Consensus(InvalidTransactionError::NonceNotConsistent { .. })) ||
488            matches!(self, Self::Eip4844(Eip4844PoolTransactionError::Eip4844NonceGap))
489    }
490
491    /// Returns the arbitrary error if it is [`InvalidPoolTransactionError::Other`]
492    pub fn as_other(&self) -> Option<&dyn PoolTransactionError> {
493        match self {
494            Self::Other(err) => Some(&**err),
495            _ => None,
496        }
497    }
498
499    /// Returns a reference to the [`InvalidPoolTransactionError::Other`] value if this type is a
500    /// [`InvalidPoolTransactionError::Other`] of that type. Returns None otherwise.
501    pub fn downcast_other_ref<T: core::error::Error + 'static>(&self) -> Option<&T> {
502        let other = self.as_other()?;
503        other.as_any().downcast_ref()
504    }
505
506    /// Returns true if the this type is a [`InvalidPoolTransactionError::Other`] of that error
507    /// type. Returns false otherwise.
508    pub fn is_other<T: core::error::Error + 'static>(&self) -> bool {
509        self.as_other().is_some_and(|err| err.as_any().is::<T>())
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    #[derive(thiserror::Error, Debug)]
518    #[error("err")]
519    struct E;
520
521    impl PoolTransactionError for E {
522        fn is_bad_transaction(&self) -> bool {
523            false
524        }
525
526        fn as_any(&self) -> &dyn Any {
527            self
528        }
529    }
530
531    #[test]
532    fn other_downcast() {
533        let err = InvalidPoolTransactionError::Other(Box::new(E));
534        assert!(err.is_other::<E>());
535
536        assert!(err.downcast_other_ref::<E>().is_some());
537    }
538
539    #[test]
540    fn bad_blob_sidecar_detection() {
541        let err = PoolError::new(
542            TxHash::ZERO,
543            InvalidPoolTransactionError::Eip4844(Eip4844PoolTransactionError::InvalidEip4844Blob(
544                BlobTransactionValidationError::InvalidProof,
545            )),
546        );
547
548        assert!(err.is_bad_blob_sidecar());
549
550        let err = PoolError::new(
551            TxHash::ZERO,
552            InvalidPoolTransactionError::Eip4844(
553                Eip4844PoolTransactionError::MissingEip4844BlobSidecar,
554            ),
555        );
556
557        assert!(err.is_bad_blob_sidecar());
558
559        let err = PoolError::new(
560            TxHash::ZERO,
561            InvalidPoolTransactionError::Eip4844(Eip4844PoolTransactionError::NoEip4844Blobs),
562        );
563
564        assert!(!err.is_bad_blob_sidecar());
565    }
566
567    #[test]
568    fn oversized_data_is_not_bad_transaction() {
569        let err = InvalidPoolTransactionError::OversizedData { size: 2048, limit: 1024 };
570        assert!(err.is_oversized());
571        assert!(!err.is_bad_transaction());
572        assert!(!PoolError::new(TxHash::ZERO, err).is_bad_transaction());
573    }
574}