Skip to main content

reth_transaction_pool/
maintain.rs

1//! Support for maintaining the state of the transaction pool
2
3use crate::{
4    blobstore::{BlobSidecarConverter, BlobStoreCanonTracker, BlobStoreUpdates},
5    error::PoolError,
6    metrics::MaintainPoolMetrics,
7    traits::{CanonicalStateUpdate, EthPoolTransaction, TransactionPool, TransactionPoolExt},
8    AllPoolTransactions, BlobTransactionSidecarVariant, BlockInfo, PoolTransaction, PoolUpdateKind,
9    TransactionOrigin,
10};
11use alloy_consensus::{transaction::TxHashRef, BlockHeader, Typed2718};
12use alloy_eips::{merge::SLOT_DURATION_SECS, BlockNumberOrTag, Decodable2718};
13use alloy_primitives::{
14    map::{AddressSet, B256Set, HashSet},
15    Address, BlockHash, BlockNumber, Bytes,
16};
17use alloy_rlp::Encodable;
18use futures_util::{
19    future::{BoxFuture, Fuse, FusedFuture},
20    FutureExt, Stream, StreamExt,
21};
22use reth_chain_state::CanonStateNotification;
23use reth_chainspec::{ChainSpecProvider, EthChainSpec, EthereumHardforks};
24use reth_execution_types::ChangedAccount;
25use reth_fs_util::FsPathError;
26use reth_primitives_traits::{
27    transaction::signed::SignedTransaction, NodePrimitives, SealedHeader,
28};
29use reth_storage_api::{errors::provider::ProviderError, BlockReaderIdExt, StateProviderFactory};
30use reth_tasks::Runtime;
31use serde::{Deserialize, Serialize};
32use std::{
33    borrow::Borrow,
34    hash::{Hash, Hasher},
35    path::{Path, PathBuf},
36    sync::Arc,
37};
38use tokio::{
39    sync::oneshot,
40    time::{self, Duration},
41};
42use tracing::{debug, error, info, trace, warn};
43
44/// Maximum amount of time non-executable transaction are queued.
45pub const MAX_QUEUED_TRANSACTION_LIFETIME: Duration = Duration::from_secs(3 * 60 * 60);
46
47/// Additional settings for maintaining the transaction pool
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct MaintainPoolConfig {
50    /// Maximum (reorg) depth we handle when updating the transaction pool: `new.number -
51    /// last_seen.number`
52    ///
53    /// Default: 64 (2 epochs)
54    pub max_update_depth: u64,
55    /// Maximum number of accounts to reload from state at once when updating the transaction pool.
56    ///
57    /// Default: 100
58    pub max_reload_accounts: usize,
59
60    /// Maximum amount of time non-executable, non local transactions are queued.
61    /// Default: 3 hours
62    pub max_tx_lifetime: Duration,
63
64    /// Apply no exemptions to the locally received transactions.
65    ///
66    /// This includes:
67    ///   - no price exemptions
68    ///   - no eviction exemptions
69    pub no_local_exemptions: bool,
70}
71
72impl Default for MaintainPoolConfig {
73    fn default() -> Self {
74        Self {
75            max_update_depth: 64,
76            max_reload_accounts: 100,
77            max_tx_lifetime: MAX_QUEUED_TRANSACTION_LIFETIME,
78            no_local_exemptions: false,
79        }
80    }
81}
82
83/// Settings for local transaction backup task
84#[derive(Debug, Clone, Default)]
85pub struct LocalTransactionBackupConfig {
86    /// Path to transactions backup file
87    pub transactions_path: Option<PathBuf>,
88}
89
90impl LocalTransactionBackupConfig {
91    /// Receive path to transactions backup and return initialized config
92    pub const fn with_local_txs_backup(transactions_path: PathBuf) -> Self {
93        Self { transactions_path: Some(transactions_path) }
94    }
95}
96
97/// Returns a spawnable future for maintaining the state of the transaction pool.
98pub fn maintain_transaction_pool_future<N, Client, P, St>(
99    client: Client,
100    pool: P,
101    events: St,
102    task_spawner: Runtime,
103    config: MaintainPoolConfig,
104) -> BoxFuture<'static, ()>
105where
106    N: NodePrimitives,
107    Client: StateProviderFactory
108        + BlockReaderIdExt<Header = N::BlockHeader>
109        + ChainSpecProvider<ChainSpec: EthChainSpec<Header = N::BlockHeader> + EthereumHardforks>
110        + Clone
111        + 'static,
112    P: TransactionPoolExt<Transaction: PoolTransaction<Consensus = N::SignedTx>, Block = N::Block>
113        + 'static,
114    St: Stream<Item = CanonStateNotification<N>> + Send + Unpin + 'static,
115{
116    async move {
117        maintain_transaction_pool(client, pool, events, task_spawner, config).await;
118    }
119    .boxed()
120}
121
122/// Maintains the state of the transaction pool by handling new blocks and reorgs.
123///
124/// This listens for any new blocks and reorgs and updates the transaction pool's state accordingly
125pub async fn maintain_transaction_pool<N, Client, P, St>(
126    client: Client,
127    pool: P,
128    mut events: St,
129    task_spawner: Runtime,
130    config: MaintainPoolConfig,
131) where
132    N: NodePrimitives,
133    Client: StateProviderFactory
134        + BlockReaderIdExt<Header = N::BlockHeader>
135        + ChainSpecProvider<ChainSpec: EthChainSpec<Header = N::BlockHeader> + EthereumHardforks>
136        + Clone
137        + 'static,
138    P: TransactionPoolExt<Transaction: PoolTransaction<Consensus = N::SignedTx>, Block = N::Block>
139        + 'static,
140    St: Stream<Item = CanonStateNotification<N>> + Send + Unpin + 'static,
141{
142    let metrics = MaintainPoolMetrics::default();
143    let MaintainPoolConfig { max_update_depth, max_reload_accounts, .. } = config;
144    // ensure the pool points to latest state
145    if let Ok(Some(latest)) = client.header_by_number_or_tag(BlockNumberOrTag::Latest) {
146        let latest = SealedHeader::seal_slow(latest);
147        let chain_spec = client.chain_spec();
148        let info = BlockInfo {
149            block_gas_limit: latest.gas_limit(),
150            last_seen_block_hash: latest.hash(),
151            last_seen_block_number: latest.number(),
152            pending_basefee: chain_spec
153                .next_block_base_fee(latest.header(), latest.timestamp())
154                .unwrap_or_default(),
155            pending_blob_fee: latest
156                .maybe_next_block_blob_fee(chain_spec.blob_params_at_timestamp(latest.timestamp())),
157        };
158        pool.set_block_info(info);
159    }
160
161    // keeps track of mined blob transaction so we can clean finalized transactions
162    let mut blob_store_tracker = BlobStoreCanonTracker::default();
163
164    // handle to the pool's blob store, used to skip cleanup while it holds no blobs
165    let blob_store = pool.blob_store();
166
167    // keeps track of the latest finalized block
168    let mut last_finalized_block =
169        FinalizedBlockTracker::new(client.finalized_block_number().ok().flatten());
170
171    // keeps track of any dirty accounts that we know of are out of sync with the pool
172    let mut dirty_addresses = HashSet::default();
173
174    // keeps track of the state of the pool wrt to blocks
175    let mut maintained_state = MaintainedPoolState::InSync;
176
177    // the future that reloads accounts from state
178    let mut reload_accounts_fut = Fuse::terminated();
179
180    // eviction interval for stale non local txs
181    let mut stale_eviction_interval = time::interval(config.max_tx_lifetime);
182
183    // toggle for the first notification
184    let mut first_event = true;
185
186    // The update loop that waits for new blocks and reorgs and performs pool updated
187    // Listen for new chain events and derive the update action for the pool
188    loop {
189        trace!(target: "txpool", state=?maintained_state, "awaiting new block or reorg");
190
191        metrics.set_dirty_accounts_len(dirty_addresses.len());
192        let pool_info = pool.block_info();
193
194        // after performing a pool update after a new block we have some time to properly update
195        // dirty accounts and correct if the pool drifted from current state, for example after
196        // restart or a pipeline run
197        if maintained_state.is_drifted() {
198            metrics.inc_drift();
199            // assuming all senders are dirty
200            dirty_addresses = pool.unique_senders();
201            // make sure we toggle the state back to in sync
202            maintained_state = MaintainedPoolState::InSync;
203        }
204
205        // if we have accounts that are out of sync with the pool, we reload them in chunks
206        if !dirty_addresses.is_empty() && reload_accounts_fut.is_terminated() {
207            let (tx, rx) = oneshot::channel();
208            let c = client.clone();
209            let at = pool_info.last_seen_block_hash;
210            let fut = if dirty_addresses.len() > max_reload_accounts {
211                // need to chunk accounts to reload
212                let accs_to_reload =
213                    dirty_addresses.iter().copied().take(max_reload_accounts).collect::<Vec<_>>();
214                for acc in &accs_to_reload {
215                    // make sure we remove them from the dirty set
216                    dirty_addresses.remove(acc);
217                }
218                async move {
219                    let res = load_accounts(c, at, accs_to_reload);
220                    let _ = tx.send(res);
221                }
222                .boxed()
223            } else {
224                // can fetch all dirty accounts at once
225                let accs_to_reload = std::mem::take(&mut dirty_addresses);
226                async move {
227                    let res = load_accounts(c, at, accs_to_reload);
228                    let _ = tx.send(res);
229                }
230                .boxed()
231            };
232            reload_accounts_fut = rx.fuse();
233            task_spawner.spawn_blocking_task(fut);
234        }
235
236        // check if we have a new finalized block
237        if let Some(finalized) =
238            last_finalized_block.update(client.finalized_block_number().ok().flatten())
239        {
240            if let BlobStoreUpdates::Finalized(blobs) =
241                blob_store_tracker.on_finalized_block(finalized)
242            {
243                metrics.inc_deleted_tracked_blobs(blobs.len());
244                // remove all finalized blobs from the blob store
245                pool.delete_blobs(blobs);
246            }
247            // and also do periodic cleanup, deletions of replaced or evicted blobs are only queued
248            // in the blob store until this runs, and queued blobs still count towards its length
249            if blob_store.blobs_len() > 0 {
250                let pool = pool.clone();
251                task_spawner.spawn_blocking_task(async move {
252                    debug!(target: "txpool", finalized_block = %finalized, "cleaning up blob store");
253                    pool.cleanup_blobs();
254                });
255            }
256        }
257
258        // outcomes of the futures we are waiting on
259        let mut event = None;
260        let mut reloaded = None;
261
262        // select of account reloads and new canonical state updates which should arrive at the rate
263        // of the block time
264        tokio::select! {
265            res = &mut reload_accounts_fut =>  {
266                reloaded = Some(res);
267            }
268            ev = events.next() =>  {
269                 if ev.is_none() {
270                    // the stream ended, we are done
271                    break;
272                }
273                event = ev;
274                // on receiving the first event on start up, mark the pool as drifted to explicitly
275                // trigger revalidation and clear out outdated txs.
276                if first_event {
277                    maintained_state = MaintainedPoolState::Drifted;
278                    first_event = false
279                }
280            }
281            _ = stale_eviction_interval.tick() => {
282                let queued = pool
283                    .queued_transactions();
284                let mut stale_blobs = Vec::new();
285                let now = std::time::Instant::now();
286                let stale_txs: Vec<_> = queued
287                    .into_iter()
288                    .filter(|tx| {
289                        // filter stale transactions based on config
290                        (tx.origin.is_external() || config.no_local_exemptions) && now - tx.timestamp > config.max_tx_lifetime
291                    })
292                    .map(|tx| {
293                        if tx.is_eip4844() {
294                            stale_blobs.push(*tx.hash());
295                        }
296                        *tx.hash()
297                    })
298                    .collect();
299                debug!(target: "txpool", count=%stale_txs.len(), "removing stale transactions");
300                pool.remove_transactions(stale_txs);
301                pool.delete_blobs(stale_blobs);
302            }
303        }
304        // handle the result of the account reload
305        match reloaded {
306            Some(Ok(Ok(LoadedAccounts { accounts, failed_to_load }))) => {
307                // reloaded accounts successfully
308                // extend accounts we failed to load from database
309                dirty_addresses.extend(failed_to_load);
310                // update the pool with the loaded accounts
311                pool.update_accounts(accounts);
312            }
313            Some(Ok(Err(res))) => {
314                // Failed to load accounts from state
315                let (accs, err) = *res;
316                debug!(target: "txpool", %err, "failed to load accounts");
317                dirty_addresses.extend(accs);
318            }
319            Some(Err(_)) => {
320                // failed to receive the accounts, sender dropped, only possible if task panicked
321                maintained_state = MaintainedPoolState::Drifted;
322            }
323            None => {}
324        }
325
326        // handle the new block or reorg
327        let Some(event) = event else { continue };
328        match event {
329            CanonStateNotification::Reorg { old, new } => {
330                let (old_blocks, old_state) = old.inner();
331                let (new_blocks, new_state) = new.inner();
332                let new_tip = new_blocks.tip();
333                let new_first = new_blocks.first();
334                let old_first = old_blocks.first();
335
336                // check if the reorg is not canonical with the pool's block
337                if !(old_first.parent_hash() == pool_info.last_seen_block_hash ||
338                    new_first.parent_hash() == pool_info.last_seen_block_hash)
339                {
340                    // the new block points to a higher block than the oldest block in the old chain
341                    maintained_state = MaintainedPoolState::Drifted;
342                }
343
344                let chain_spec = client.chain_spec();
345
346                // fees for the next block: `new_tip+1`
347                let pending_block_base_fee = chain_spec
348                    .next_block_base_fee(new_tip.header(), new_tip.timestamp())
349                    .unwrap_or_default();
350                let pending_block_blob_fee = new_tip.header().maybe_next_block_blob_fee(
351                    chain_spec.blob_params_at_timestamp(new_tip.timestamp()),
352                );
353
354                // we know all changed account in the new chain
355                let new_changed_accounts: HashSet<_> =
356                    new_state.changed_accounts().map(ChangedAccountEntry).collect();
357
358                // find all accounts that were changed in the old chain but _not_ in the new chain
359                let missing_changed_acc = old_state
360                    .accounts_iter()
361                    .map(|(a, _)| a)
362                    .filter(|addr| !new_changed_accounts.contains(addr));
363
364                // for these we need to fetch the nonce+balance from the db at the new tip
365                let mut changed_accounts =
366                    match load_accounts(client.clone(), new_tip.hash(), missing_changed_acc) {
367                        Ok(LoadedAccounts { accounts, failed_to_load }) => {
368                            // extend accounts we failed to load from database
369                            dirty_addresses.extend(failed_to_load);
370
371                            accounts
372                        }
373                        Err(err) => {
374                            let (addresses, err) = *err;
375                            debug!(
376                                target: "txpool",
377                                %err,
378                                "failed to load missing changed accounts at new tip: {:?}",
379                                new_tip.hash()
380                            );
381                            dirty_addresses.extend(addresses);
382                            vec![]
383                        }
384                    };
385
386                // also include all accounts from new chain
387                // we can use extend here because they are unique
388                changed_accounts.extend(new_changed_accounts.into_iter().map(|entry| entry.0));
389
390                // all transactions mined in the new chain
391                let mined_transactions = new_blocks.transaction_hashes_vec();
392                let new_mined_transactions =
393                    mined_transactions.iter().copied().collect::<B256Set>();
394
395                // update the pool then re-inject the pruned transactions
396                // find all transactions that were mined in the old chain but not in the new chain
397                let pruned_old_transactions = old_blocks
398                    .transactions_with_sender()
399                    .filter(|(_, tx)| !new_mined_transactions.contains(tx.tx_hash()))
400                    .filter_map(|(signer, tx)| {
401                        let tx = tx.clone().with_signer(*signer);
402                        if tx.is_eip4844() {
403                            // reorged blobs no longer include the blob, which is necessary for
404                            // validating the transaction. Even though the transaction could have
405                            // been validated previously, we still need the blob in order to
406                            // accurately set the transaction's
407                            // encoded-length which is propagated over the network.
408                            pool.get_blob(*tx.tx_hash())
409                                .ok()
410                                .flatten()
411                                .map(Arc::unwrap_or_clone)
412                                .and_then(|sidecar| {
413                                    <P as TransactionPool>::Transaction::try_from_eip4844(
414                                        tx, sidecar,
415                                    )
416                                })
417                        } else {
418                            <P as TransactionPool>::Transaction::try_from_consensus(tx).ok()
419                        }
420                    })
421                    .collect::<Vec<_>>();
422
423                // update the pool first
424                let update = CanonicalStateUpdate {
425                    new_tip: new_tip.sealed_block(),
426                    pending_block_base_fee,
427                    pending_block_blob_fee,
428                    changed_accounts,
429                    // all transactions mined in the new chain need to be removed from the pool
430                    mined_transactions,
431                    update_kind: PoolUpdateKind::Reorg,
432                };
433                pool.on_canonical_state_change(update);
434
435                // all transactions that were mined in the old chain but not in the new chain need
436                // to be re-injected
437                //
438                // Note: we no longer know if the tx was local or external
439                // Because the transactions are not finalized, the corresponding blobs are still in
440                // blob store (if we previously received them from the network)
441                metrics.inc_reinserted_transactions(pruned_old_transactions.len());
442                let _ = pool.add_external_transactions(pruned_old_transactions).await;
443
444                // keep track of new mined blob transactions
445                blob_store_tracker.add_new_chain_blocks(&new_blocks);
446            }
447            CanonStateNotification::Commit { new } => {
448                let (blocks, state) = new.inner();
449                let tip = blocks.tip();
450                let chain_spec = client.chain_spec();
451
452                // fees for the next block: `tip+1`
453                let pending_block_base_fee = chain_spec
454                    .next_block_base_fee(tip.header(), tip.timestamp())
455                    .unwrap_or_default();
456                let pending_block_blob_fee = tip.header().maybe_next_block_blob_fee(
457                    chain_spec.blob_params_at_timestamp(tip.timestamp()),
458                );
459
460                let first_block = blocks.first();
461                trace!(
462                    target: "txpool",
463                    first = first_block.number(),
464                    tip = tip.number(),
465                    pool_block = pool_info.last_seen_block_number,
466                    "update pool on new commit"
467                );
468
469                // check if the depth is too large and should be skipped, this could happen after
470                // initial sync or long re-sync
471                let depth = tip.number().abs_diff(pool_info.last_seen_block_number);
472                if depth > max_update_depth {
473                    maintained_state = MaintainedPoolState::Drifted;
474                    debug!(target: "txpool", ?depth, "skipping deep canonical update");
475                    // move the pool and the validator to the new tip without updating accounts or
476                    // removing mined transactions
477                    let update = CanonicalStateUpdate {
478                        new_tip: tip.sealed_block(),
479                        pending_block_base_fee,
480                        pending_block_blob_fee,
481                        changed_accounts: Vec::new(),
482                        mined_transactions: Vec::new(),
483                        update_kind: PoolUpdateKind::Commit,
484                    };
485                    pool.on_canonical_state_change(update);
486
487                    // keep track of mined blob transactions
488                    blob_store_tracker.add_new_chain_blocks(&blocks);
489
490                    continue
491                }
492
493                let mut changed_accounts = Vec::with_capacity(state.state().len());
494                for acc in state.changed_accounts() {
495                    // we can always clear the dirty flag for this account
496                    dirty_addresses.remove(&acc.address);
497                    changed_accounts.push(acc);
498                }
499
500                let mined_transactions = blocks.transaction_hashes_vec();
501
502                // check if the range of the commit is canonical with the pool's block
503                if first_block.parent_hash() != pool_info.last_seen_block_hash {
504                    // we received a new canonical chain commit but the commit is not canonical with
505                    // the pool's block, this could happen after initial sync or
506                    // long re-sync
507                    maintained_state = MaintainedPoolState::Drifted;
508                }
509
510                // Canonical update
511                let update = CanonicalStateUpdate {
512                    new_tip: tip.sealed_block(),
513                    pending_block_base_fee,
514                    pending_block_blob_fee,
515                    changed_accounts,
516                    mined_transactions,
517                    update_kind: PoolUpdateKind::Commit,
518                };
519                pool.on_canonical_state_change(update);
520
521                // keep track of mined blob transactions
522                blob_store_tracker.add_new_chain_blocks(&blocks);
523
524                // If Osaka activates in 2 slots we need to convert blobs to new format.
525                if !chain_spec.is_osaka_active_at_timestamp(tip.timestamp()) &&
526                    !chain_spec.is_osaka_active_at_timestamp(
527                        tip.timestamp().saturating_add(SLOT_DURATION_SECS),
528                    ) &&
529                    chain_spec.is_osaka_active_at_timestamp(
530                        tip.timestamp().saturating_add(2 * SLOT_DURATION_SECS),
531                    )
532                {
533                    let pool = pool.clone();
534                    let spawner = task_spawner.clone();
535                    let client = client.clone();
536                    task_spawner.spawn_task(async move {
537                        // Start converting not eaerlier than 4 seconds into current slot to ensure
538                        // that our pool only contains valid transactions for the next block (as
539                        // it's not Osaka yet).
540                        tokio::time::sleep(Duration::from_secs(4)).await;
541
542                        let mut interval = tokio::time::interval(Duration::from_secs(1));
543                        loop {
544                            // Loop and replace blob transactions until we reach Osaka transition
545                            // block after which no legacy blobs are going to be accepted.
546                            let last_iteration =
547                                client.latest_header().ok().flatten().is_none_or(|header| {
548                                    client
549                                        .chain_spec()
550                                        .is_osaka_active_at_timestamp(header.timestamp())
551                                });
552
553                            let AllPoolTransactions { pending, queued } = pool.all_transactions();
554                            for tx in pending.into_iter().chain(queued).filter(|tx| tx.is_eip4844())
555                            {
556                                let tx_hash = *tx.hash();
557
558                                // Fetch sidecar from the pool
559                                let Ok(Some(sidecar)) = pool.get_blob(tx_hash) else {
560                                    continue;
561                                };
562                                // Ensure it is a legacy blob
563                                if !sidecar.is_eip4844() {
564                                    continue;
565                                }
566                                // Remove transaction and sidecar from the pool, both are in memory
567                                // now
568                                let Some(tx) = pool.remove_transactions(vec![tx_hash]).pop() else {
569                                    continue;
570                                };
571                                pool.delete_blob(tx_hash);
572
573                                let BlobTransactionSidecarVariant::Eip4844(sidecar) =
574                                    Arc::unwrap_or_clone(sidecar)
575                                else {
576                                    continue;
577                                };
578
579                                let converter = BlobSidecarConverter::new();
580                                let pool = pool.clone();
581                                spawner.spawn_task(async move {
582                                    // Convert sidecar to EIP-7594 format
583                                    let Some(sidecar) = converter.convert(sidecar).await else {
584                                        return;
585                                    };
586
587                                    // Re-insert transaction with the new sidecar
588                                    let origin = tx.origin;
589                                    let Some(tx) = EthPoolTransaction::try_from_eip4844(
590                                        tx.to_consensus(),
591                                        sidecar.into(),
592                                    ) else {
593                                        return;
594                                    };
595                                    let _ = pool.add_transaction(origin, tx).await;
596                                });
597                            }
598
599                            if last_iteration {
600                                break;
601                            }
602
603                            interval.tick().await;
604                        }
605                    });
606                }
607            }
608        }
609    }
610}
611
612struct FinalizedBlockTracker {
613    last_finalized_block: Option<BlockNumber>,
614}
615
616impl FinalizedBlockTracker {
617    const fn new(last_finalized_block: Option<BlockNumber>) -> Self {
618        Self { last_finalized_block }
619    }
620
621    /// Updates the tracked finalized block and returns the new finalized block if it changed
622    fn update(&mut self, finalized_block: Option<BlockNumber>) -> Option<BlockNumber> {
623        let finalized = finalized_block?;
624        self.last_finalized_block.is_none_or(|last| last < finalized).then(|| {
625            self.last_finalized_block = Some(finalized);
626            finalized
627        })
628    }
629}
630
631/// Keeps track of the pool's state, whether the accounts in the pool are in sync with the actual
632/// state.
633#[derive(Debug, PartialEq, Eq)]
634enum MaintainedPoolState {
635    /// Pool is assumed to be in sync with the current state
636    InSync,
637    /// Pool could be out of sync with the state
638    Drifted,
639}
640
641impl MaintainedPoolState {
642    /// Returns `true` if the pool is assumed to be out of sync with the current state.
643    #[inline]
644    const fn is_drifted(&self) -> bool {
645        matches!(self, Self::Drifted)
646    }
647}
648
649/// A unique [`ChangedAccount`] identified by its address that can be used for deduplication
650#[derive(Eq)]
651struct ChangedAccountEntry(ChangedAccount);
652
653impl PartialEq for ChangedAccountEntry {
654    fn eq(&self, other: &Self) -> bool {
655        self.0.address == other.0.address
656    }
657}
658
659impl Hash for ChangedAccountEntry {
660    fn hash<H: Hasher>(&self, state: &mut H) {
661        self.0.address.hash(state);
662    }
663}
664
665impl Borrow<Address> for ChangedAccountEntry {
666    fn borrow(&self) -> &Address {
667        &self.0.address
668    }
669}
670
671#[derive(Default)]
672struct LoadedAccounts {
673    /// All accounts that were loaded
674    accounts: Vec<ChangedAccount>,
675    /// All accounts that failed to load
676    failed_to_load: Vec<Address>,
677}
678
679/// Loads all accounts at the given state
680///
681/// Returns an error with all given addresses if the state is not available.
682///
683/// Note: this expects _unique_ addresses
684fn load_accounts<Client, I>(
685    client: Client,
686    at: BlockHash,
687    addresses: I,
688) -> Result<LoadedAccounts, Box<(AddressSet, ProviderError)>>
689where
690    I: IntoIterator<Item = Address>,
691    Client: StateProviderFactory,
692{
693    let addresses = addresses.into_iter();
694    let mut res = LoadedAccounts::default();
695    let state = match client.history_by_block_hash(at) {
696        Ok(state) => state,
697        Err(err) => return Err(Box::new((addresses.collect(), err))),
698    };
699    for addr in addresses {
700        if let Ok(maybe_acc) = state.basic_account(&addr) {
701            let acc = maybe_acc
702                .map(|acc| ChangedAccount { address: addr, nonce: acc.nonce, balance: acc.balance })
703                .unwrap_or_else(|| ChangedAccount::empty(addr));
704            res.accounts.push(acc)
705        } else {
706            // failed to load account.
707            res.failed_to_load.push(addr);
708        }
709    }
710    Ok(res)
711}
712
713/// Loads transactions from a file, decodes them from the JSON or RLP format, and
714/// inserts them into the transaction pool on node boot up.
715/// The file is removed after the transactions have been successfully processed.
716async fn load_and_reinsert_transactions<P>(
717    pool: P,
718    file_path: &Path,
719) -> Result<(), TransactionsBackupError>
720where
721    P: TransactionPool<Transaction: PoolTransaction<Consensus: SignedTransaction>>,
722{
723    if !file_path.exists() {
724        return Ok(())
725    }
726
727    debug!(target: "txpool", txs_file =?file_path, "Check local persistent storage for saved transactions");
728    let data = reth_fs_util::read(file_path)?;
729
730    if data.is_empty() {
731        return Ok(())
732    }
733
734    let pool_transactions: Vec<(TransactionOrigin, <P as TransactionPool>::Transaction)> =
735        if let Ok(tx_backups) = serde_json::from_slice::<Vec<TxBackup>>(&data) {
736            tx_backups
737                .into_iter()
738                .filter_map(|backup| {
739                    let tx_signed =
740                        <P::Transaction as PoolTransaction>::Consensus::decode_2718_exact(
741                            backup.rlp.as_ref(),
742                        )
743                        .ok()?;
744                    let recovered = tx_signed.try_into_recovered().ok()?;
745                    let pool_tx =
746                        <P::Transaction as PoolTransaction>::try_from_consensus(recovered).ok()?;
747
748                    Some((backup.origin, pool_tx))
749                })
750                .collect()
751        } else {
752            let txs_signed: Vec<<P::Transaction as PoolTransaction>::Consensus> =
753                alloy_rlp::Decodable::decode(&mut data.as_slice())?;
754
755            txs_signed
756                .into_iter()
757                .filter_map(|tx| tx.try_into_recovered().ok())
758                .filter_map(|tx| {
759                    <P::Transaction as PoolTransaction>::try_from_consensus(tx)
760                        .ok()
761                        .map(|pool_tx| (TransactionOrigin::Local, pool_tx))
762                })
763                .collect()
764        };
765
766    let inserted = futures_util::future::join_all(
767        pool_transactions.into_iter().map(|(origin, tx)| pool.add_transaction(origin, tx)),
768    )
769    .await;
770
771    info!(target: "txpool", txs_file =?file_path, num_txs=%inserted.len(), "Successfully reinserted local transactions from file");
772    reth_fs_util::remove_file(file_path)?;
773    Ok(())
774}
775
776fn save_local_txs_backup<P>(pool: P, file_path: &Path)
777where
778    P: TransactionPool<Transaction: PoolTransaction<Consensus: Encodable>>,
779{
780    let local_transactions = pool.get_local_transactions();
781    if local_transactions.is_empty() {
782        trace!(target: "txpool", "no local transactions to save");
783        return
784    }
785
786    let local_transactions = local_transactions
787        .into_iter()
788        .map(|tx| TxBackup { rlp: tx.encoded_2718_consensus(), origin: tx.origin })
789        .collect::<Vec<_>>();
790
791    let json_data = match serde_json::to_string(&local_transactions) {
792        Ok(data) => data,
793        Err(err) => {
794            warn!(target: "txpool", %err, txs_file=?file_path, "failed to serialize local transactions to json");
795            return
796        }
797    };
798
799    info!(target: "txpool", txs_file =?file_path, num_txs=%local_transactions.len(), "Saving current local transactions");
800    let parent_dir = file_path.parent().map(std::fs::create_dir_all).transpose();
801
802    match parent_dir.map(|_| reth_fs_util::write(file_path, json_data)) {
803        Ok(_) => {
804            info!(target: "txpool", txs_file=?file_path, "Wrote local transactions to file");
805        }
806        Err(err) => {
807            warn!(target: "txpool", %err, txs_file=?file_path, "Failed to write local transactions to file");
808        }
809    }
810}
811
812/// A transaction backup that is saved as json to a file for
813/// reinsertion into the pool
814#[derive(Debug, Deserialize, Serialize)]
815pub struct TxBackup {
816    /// Encoded transaction
817    pub rlp: Bytes,
818    /// The origin of the transaction
819    pub origin: TransactionOrigin,
820}
821
822/// Errors possible during txs backup load and decode
823#[derive(thiserror::Error, Debug)]
824pub enum TransactionsBackupError {
825    /// Error during RLP decoding of transactions
826    #[error("failed to apply transactions backup. Encountered RLP decode error: {0}")]
827    Decode(#[from] alloy_rlp::Error),
828    /// Error during json decoding of transactions
829    #[error("failed to apply transactions backup. Encountered JSON decode error: {0}")]
830    Json(#[from] serde_json::Error),
831    /// Error during file upload
832    #[error("failed to apply transactions backup. Encountered file error: {0}")]
833    FsPath(#[from] FsPathError),
834    /// Error adding transactions to the transaction pool
835    #[error("failed to insert transactions to the transactions pool. Encountered pool error: {0}")]
836    Pool(#[from] PoolError),
837}
838
839/// Task which manages saving local transactions to the persistent file in case of shutdown.
840/// Reloads the transactions from the file on the boot up and inserts them into the pool.
841pub async fn backup_local_transactions_task<P>(
842    shutdown: reth_tasks::shutdown::GracefulShutdown,
843    pool: P,
844    config: LocalTransactionBackupConfig,
845) where
846    P: TransactionPool<Transaction: PoolTransaction<Consensus: SignedTransaction>>,
847{
848    let Some(transactions_path) = config.transactions_path else {
849        // nothing to do
850        return
851    };
852
853    if let Err(err) = load_and_reinsert_transactions(pool.clone(), &transactions_path).await {
854        error!(target: "txpool", "{}", err)
855    }
856
857    let graceful_guard = shutdown.await;
858
859    // write transactions to disk
860    save_local_txs_backup(pool, &transactions_path);
861
862    drop(graceful_guard)
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868    use crate::{
869        blobstore::InMemoryBlobStore,
870        error::{InvalidPoolTransactionError, PoolErrorKind},
871        test_utils::TransactionBuilder,
872        validate::EthTransactionValidatorBuilder,
873        CoinbaseTipOrdering, EthPooledTransaction, Pool, TransactionOrigin,
874    };
875    use alloy_consensus::Header;
876    use alloy_eips::{eip1559::ETHEREUM_BLOCK_GAS_LIMIT_30M, eip2718::Decodable2718};
877    use alloy_primitives::{address, hex, B256, U256};
878    use reth_ethereum_primitives::{Block, PooledTransactionVariant};
879    use reth_evm_ethereum::EthEvmConfig;
880    use reth_execution_types::Chain;
881    use reth_fs_util as fs;
882    use reth_primitives_traits::{RecoveredBlock, SealedBlock};
883    use reth_provider::test_utils::{ExtendedAccount, MockEthProvider};
884    use reth_tasks::Runtime;
885
886    #[test]
887    fn changed_acc_entry() {
888        let changed_acc = ChangedAccountEntry(ChangedAccount::empty(Address::random()));
889        let mut copy = changed_acc.0;
890        copy.nonce = 10;
891        assert!(changed_acc.eq(&ChangedAccountEntry(copy)));
892    }
893
894    const EXTENSION: &str = "json";
895    const FILENAME: &str = "test_transactions_backup";
896
897    #[tokio::test(flavor = "multi_thread")]
898    async fn test_save_local_txs_backup() {
899        let temp_dir = tempfile::tempdir().unwrap();
900        let transactions_path = temp_dir.path().join(FILENAME).with_extension(EXTENSION);
901        let tx_bytes = hex!(
902            "02f87201830655c2808505ef61f08482565f94388c818ca8b9251b393131c08a736a67ccb192978801049e39c4b5b1f580c001a01764ace353514e8abdfb92446de356b260e3c1225b73fc4c8876a6258d12a129a04f02294aa61ca7676061cd99f29275491218b4754b46a0248e5e42bc5091f507"
903        );
904        let tx = PooledTransactionVariant::decode_2718(&mut &tx_bytes[..]).unwrap();
905        let provider = MockEthProvider::default().with_genesis_block();
906        let transaction = EthPooledTransaction::from_pooled(tx.try_into_recovered().unwrap());
907        let tx_to_cmp = transaction.clone();
908        let sender = address!("1f9090aaE28b8a3dCeaDf281B0F12828e676c326");
909        provider.add_account(sender, ExtendedAccount::new(42, U256::MAX));
910        let blob_store = InMemoryBlobStore::default();
911        let validator = EthTransactionValidatorBuilder::new(provider, EthEvmConfig::mainnet())
912            .set_block_gas_limit(ETHEREUM_BLOCK_GAS_LIMIT_30M)
913            .build(blob_store.clone());
914
915        let txpool = Pool::new(
916            validator,
917            CoinbaseTipOrdering::default(),
918            blob_store.clone(),
919            Default::default(),
920        );
921
922        txpool.add_transaction(TransactionOrigin::Local, transaction.clone()).await.unwrap();
923
924        let rt = Runtime::test();
925        let config = LocalTransactionBackupConfig::with_local_txs_backup(transactions_path.clone());
926        rt.spawn_critical_with_graceful_shutdown_signal("test task", |shutdown| {
927            backup_local_transactions_task(shutdown, txpool.clone(), config)
928        });
929
930        let mut txns = txpool.get_local_transactions();
931        let tx_on_finish = txns.pop().expect("there should be 1 transaction");
932
933        assert_eq!(*tx_to_cmp.hash(), *tx_on_finish.hash());
934
935        rt.graceful_shutdown();
936
937        let data = fs::read(transactions_path).unwrap();
938
939        let txs: Vec<TxBackup> = serde_json::from_slice::<Vec<TxBackup>>(&data).unwrap();
940        assert_eq!(txs.len(), 1);
941
942        temp_dir.close().unwrap();
943    }
944
945    #[tokio::test(flavor = "multi_thread")]
946    async fn deep_commit_refreshes_validator_head() {
947        // mainnet genesis has a 5,000 gas limit, which the validator picks up from the latest
948        // header
949        let provider = MockEthProvider::default().with_genesis_block();
950        let transaction = EthPooledTransaction::try_from_consensus(
951            TransactionBuilder::default()
952                .gas_limit(21_000)
953                .max_fee_per_gas(1_000_000_000)
954                .to(Address::ZERO)
955                .into_eip1559()
956                .try_into_recovered()
957                .unwrap(),
958        )
959        .unwrap();
960        provider.add_account(transaction.sender(), ExtendedAccount::new(0, U256::MAX));
961        let blob_store = InMemoryBlobStore::default();
962        let validator =
963            EthTransactionValidatorBuilder::new(provider.clone(), EthEvmConfig::mainnet())
964                .build(blob_store.clone());
965        let txpool =
966            Pool::new(validator, CoinbaseTipOrdering::default(), blob_store, Default::default());
967
968        let err = txpool
969            .add_transaction(TransactionOrigin::External, transaction.clone())
970            .await
971            .unwrap_err();
972        assert!(matches!(
973            err.kind,
974            PoolErrorKind::InvalidTransaction(InvalidPoolTransactionError::ExceedsGasLimit(
975                21_000, 5_000
976            ))
977        ));
978
979        let config = MaintainPoolConfig::default();
980        let tip = RecoveredBlock::<Block>::new_sealed(
981            SealedBlock::from_sealed_parts(
982                SealedHeader::new(
983                    Header {
984                        number: config.max_update_depth + 1,
985                        gas_limit: ETHEREUM_BLOCK_GAS_LIMIT_30M,
986                        ..Default::default()
987                    },
988                    B256::random(),
989                ),
990                Default::default(),
991            ),
992            Default::default(),
993        );
994        let commit: CanonStateNotification = CanonStateNotification::Commit {
995            new: Arc::new(Chain::new(vec![tip], Default::default(), Default::default())),
996        };
997        let maintain = maintain_transaction_pool(
998            provider,
999            txpool.clone(),
1000            futures::stream::iter([commit]),
1001            Runtime::test(),
1002            config,
1003        );
1004        time::timeout(Duration::from_secs(10), maintain).await.unwrap();
1005        assert_eq!(txpool.block_info().last_seen_block_number, config.max_update_depth + 1);
1006
1007        let hash = *transaction.hash();
1008        let added = txpool.add_transaction(TransactionOrigin::External, transaction).await.unwrap();
1009        assert_eq!(added.hash, hash);
1010    }
1011
1012    #[test]
1013    fn test_update_with_higher_finalized_block() {
1014        let mut tracker = FinalizedBlockTracker::new(Some(10));
1015        assert_eq!(tracker.update(Some(15)), Some(15));
1016        assert_eq!(tracker.last_finalized_block, Some(15));
1017    }
1018
1019    #[test]
1020    fn test_update_with_lower_finalized_block() {
1021        let mut tracker = FinalizedBlockTracker::new(Some(20));
1022        assert_eq!(tracker.update(Some(15)), None);
1023        // finalized block should NOT go backwards
1024        assert_eq!(tracker.last_finalized_block, Some(20));
1025    }
1026
1027    #[test]
1028    fn test_update_with_equal_finalized_block() {
1029        let mut tracker = FinalizedBlockTracker::new(Some(20));
1030        assert_eq!(tracker.update(Some(20)), None);
1031        assert_eq!(tracker.last_finalized_block, Some(20));
1032    }
1033
1034    #[test]
1035    fn test_update_with_no_last_finalized_block() {
1036        let mut tracker = FinalizedBlockTracker::new(None);
1037        assert_eq!(tracker.update(Some(10)), Some(10));
1038        assert_eq!(tracker.last_finalized_block, Some(10));
1039    }
1040
1041    #[test]
1042    fn test_update_with_no_new_finalized_block() {
1043        let mut tracker = FinalizedBlockTracker::new(Some(10));
1044        assert_eq!(tracker.update(None), None);
1045        assert_eq!(tracker.last_finalized_block, Some(10));
1046    }
1047
1048    #[test]
1049    fn test_update_with_no_finalized_blocks() {
1050        let mut tracker = FinalizedBlockTracker::new(None);
1051        assert_eq!(tracker.update(None), None);
1052        assert_eq!(tracker.last_finalized_block, None);
1053    }
1054}