Skip to main content

reth_ethereum_payload_builder/
lib.rs

1//! A basic Ethereum payload builder implementation.
2
3#![doc(
4    html_logo_url = "https://raw.githubusercontent.com/paradigmxyz/reth/main/assets/reth-docs.png",
5    html_favicon_url = "https://avatars0.githubusercontent.com/u/97369466?s=256",
6    issue_tracker_base_url = "https://github.com/paradigmxyz/reth/issues/"
7)]
8#![cfg_attr(not(test), warn(unused_crate_dependencies))]
9#![cfg_attr(docsrs, feature(doc_cfg))]
10
11use alloy_consensus::Transaction;
12use alloy_primitives::U256;
13use alloy_rlp::Encodable;
14use alloy_rpc_types_engine::PayloadAttributes as EthPayloadAttributes;
15use reth_basic_payload_builder::{
16    is_better_payload, BuildArguments, BuildOutcome, MissingPayloadBehaviour, PayloadBuilder,
17    PayloadConfig,
18};
19use reth_chainspec::{ChainSpecProvider, EthChainSpec, EthereumHardforks};
20use reth_consensus_common::validation::MAX_RLP_BLOCK_SIZE;
21use reth_errors::ConsensusError;
22use reth_ethereum_primitives::{EthPrimitives, TransactionSigned};
23use reth_evm::{
24    database::StateProviderDatabase,
25    execute::{
26        BlockBuilder, BlockBuilderOutcome, BlockExecutionError, BlockExecutor, BlockValidationError,
27    },
28    ConfigureEvm, EvmEnv, NextBlockEnvAttributes, OnStateHook,
29};
30use reth_evm_ethereum::EthEvmConfig;
31use reth_execution_cache::{CachedStateMetrics, CachedStateMetricsSource, CachedStateProvider};
32use reth_payload_builder::{BlobSidecars, EthBuiltPayload};
33use reth_payload_builder_primitives::PayloadBuilderError;
34use reth_payload_primitives::PayloadAttributes as _;
35use reth_primitives_traits::transaction::error::InvalidTransactionError;
36use reth_storage_api::{EvmStateProvider, StateProvider, StateProviderFactory};
37use reth_transaction_pool::{
38    error::{Eip4844PoolTransactionError, InvalidPoolTransactionError},
39    BestTransactions, BestTransactionsAttributes, PoolTransaction, TransactionPool,
40    ValidPoolTransaction,
41};
42use reth_trie_common::updates::TrieUpdates;
43use std::sync::Arc;
44use tracing::{debug, trace, warn};
45
46mod config;
47pub use config::*;
48
49pub mod validator;
50pub use validator::EthereumExecutionPayloadValidator;
51
52type BestTransactionsIter<Pool> = Box<
53    dyn BestTransactions<Item = Arc<ValidPoolTransaction<<Pool as TransactionPool>::Transaction>>>,
54>;
55
56/// Ethereum payload builder
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct EthereumPayloadBuilder<Pool, Client, EvmConfig = EthEvmConfig> {
59    /// Client providing access to node state.
60    client: Client,
61    /// Transaction pool.
62    pool: Pool,
63    /// The type responsible for creating the evm.
64    evm_config: EvmConfig,
65    /// Payload builder configuration.
66    builder_config: EthereumBuilderConfig,
67}
68
69impl<Pool, Client, EvmConfig> EthereumPayloadBuilder<Pool, Client, EvmConfig> {
70    /// `EthereumPayloadBuilder` constructor.
71    pub const fn new(
72        client: Client,
73        pool: Pool,
74        evm_config: EvmConfig,
75        builder_config: EthereumBuilderConfig,
76    ) -> Self {
77        Self { client, pool, evm_config, builder_config }
78    }
79}
80
81// Default implementation of [PayloadBuilder] for unit type
82impl<Pool, Client, EvmConfig> PayloadBuilder for EthereumPayloadBuilder<Pool, Client, EvmConfig>
83where
84    EvmConfig: ConfigureEvm<Primitives = EthPrimitives, NextBlockEnvCtx = NextBlockEnvAttributes>,
85    Client: StateProviderFactory + ChainSpecProvider<ChainSpec: EthereumHardforks> + Clone,
86    Pool: TransactionPool<Transaction: PoolTransaction<Consensus = TransactionSigned>>,
87{
88    type Attributes = EthPayloadAttributes;
89    type BuiltPayload = EthBuiltPayload;
90
91    fn try_build(
92        &self,
93        args: BuildArguments<EthPayloadAttributes, EthBuiltPayload>,
94    ) -> Result<BuildOutcome<EthBuiltPayload>, PayloadBuilderError> {
95        default_ethereum_payload(
96            self.evm_config.clone(),
97            self.client.clone(),
98            self.pool.clone(),
99            self.builder_config.clone(),
100            args,
101            |attributes| self.pool.best_transactions_with_attributes(attributes),
102        )
103    }
104
105    fn on_missing_payload(
106        &self,
107        _args: BuildArguments<Self::Attributes, Self::BuiltPayload>,
108    ) -> MissingPayloadBehaviour<Self::BuiltPayload> {
109        if self.builder_config.await_payload_on_missing {
110            MissingPayloadBehaviour::AwaitInProgress
111        } else {
112            MissingPayloadBehaviour::RaceEmptyPayload
113        }
114    }
115
116    fn build_empty_payload(
117        &self,
118        config: PayloadConfig<Self::Attributes>,
119    ) -> Result<EthBuiltPayload, PayloadBuilderError> {
120        let args = BuildArguments::new(
121            Default::default(),
122            Default::default(),
123            None,
124            config,
125            Default::default(),
126            None,
127        );
128
129        default_ethereum_payload(
130            self.evm_config.clone(),
131            self.client.clone(),
132            self.pool.clone(),
133            self.builder_config.clone(),
134            args,
135            |_| -> BestTransactionsIter<Pool> { Box::new(std::iter::empty()) },
136        )?
137        .into_payload()
138        .ok_or_else(|| PayloadBuilderError::MissingPayload)
139    }
140}
141
142/// Constructs an Ethereum transaction payload using the best transactions from the pool.
143///
144/// Given build arguments including an Ethereum client, transaction pool,
145/// and configuration, this function creates a transaction payload. Returns
146/// a result indicating success with the payload or an error in case of failure.
147#[inline]
148pub fn default_ethereum_payload<EvmConfig, Client, Pool, F>(
149    evm_config: EvmConfig,
150    client: Client,
151    pool: Pool,
152    builder_config: EthereumBuilderConfig,
153    args: BuildArguments<EthPayloadAttributes, EthBuiltPayload>,
154    best_txs: F,
155) -> Result<BuildOutcome<EthBuiltPayload>, PayloadBuilderError>
156where
157    EvmConfig: ConfigureEvm<Primitives = EthPrimitives, NextBlockEnvCtx = NextBlockEnvAttributes>,
158    Client: StateProviderFactory + ChainSpecProvider<ChainSpec: EthereumHardforks>,
159    Pool: TransactionPool<Transaction: PoolTransaction<Consensus = TransactionSigned>>,
160    F: FnOnce(BestTransactionsAttributes) -> BestTransactionsIter<Pool>,
161{
162    let BuildArguments {
163        mut cached_reads,
164        execution_cache,
165        mut state_root_handle,
166        config,
167        cancel,
168        best_payload,
169    } = args;
170    let PayloadConfig { parent_header, attributes, payload_id, .. } = config;
171    let skip_state_root = builder_config.skip_state_root;
172
173    let state_provider = client.state_by_block_hash(parent_header.hash())?;
174    let evm_state_provider = (&state_provider).into_evm_state_provider();
175    let cached_state_provider = execution_cache.map(|execution_cache| {
176        CachedStateProvider::new(
177            &evm_state_provider,
178            execution_cache.cache().clone(),
179            // It's ok to recreate the cache every time, because it's cheap to do so for a vanilla
180            // Ethereum builder every 12s.
181            Some(CachedStateMetrics::zeroed(CachedStateMetricsSource::Builder)),
182        )
183    });
184    let state = StateProviderDatabase::new(
185        cached_state_provider
186            .as_ref()
187            .map(|provider| provider as &dyn EvmStateProvider)
188            .unwrap_or(&evm_state_provider),
189    );
190    let chain_spec = client.chain_spec();
191    let is_amsterdam = chain_spec.is_amsterdam_active_at_timestamp(attributes.timestamp());
192    let gas_limit = builder_config
193        .gas_limit_with_target(parent_header.gas_limit, attributes.target_gas_limit());
194    let blob_params = chain_spec.blob_params_at_timestamp(attributes.timestamp());
195
196    debug!(
197        target: "payload_builder",
198        id = %payload_id,
199        parent_header = ?parent_header.hash(),
200        parent_number = parent_header.number,
201        "building payload"
202    );
203
204    let cached_db = cached_reads.as_db_mut(state);
205    let evm_config = evm_config.with_jit_support();
206    let stream_state_updates = state_root_handle.is_some() && !skip_state_root;
207    let next_block_env_attributes = NextBlockEnvAttributes {
208        timestamp: attributes.timestamp(),
209        suggested_fee_recipient: attributes.suggested_fee_recipient,
210        prev_randao: attributes.prev_randao,
211        gas_limit,
212        parent_beacon_block_root: attributes.parent_beacon_block_root(),
213        withdrawals: attributes.withdrawals.clone().map(Into::into),
214        extra_data: builder_config.extra_data.clone(),
215        slot_number: attributes.slot_number(),
216    };
217    let mut builder = evm_config
218        .builder_for_next_block(cached_db, &parent_header, next_block_env_attributes)
219        .map_err(PayloadBuilderError::other)?;
220    if is_amsterdam {
221        builder.executor_mut().enable_block_access_list_builder();
222    }
223    let base_fee = builder.evm_env().block_base_fee();
224    let blob_fee = blob_params.as_ref().map(|_| builder.evm_env().block_blob_base_fee());
225    let separate_block_gas = builder.evm_env().uses_separate_block_gas();
226    let regular_gas_limit_cap = builder.evm_env().regular_gas_limit_cap();
227
228    // If we have a state-root task, wire a state hook that streams per-tx state diffs.
229    let use_state_root_task =
230        if let Some(task) = state_root_handle.as_mut().filter(|_| stream_state_updates) {
231            let mut hook = task.take_state_hook();
232            builder.set_state_hook(move |state| hook.on_state(state))
233        } else {
234            false
235        };
236
237    builder.apply_pre_execution_changes().map_err(|err| {
238        warn!(target: "payload_builder", %err, "failed to apply pre-execution changes");
239        PayloadBuilderError::Internal(err.into())
240    })?;
241
242    let mut best_txs = best_txs(BestTransactionsAttributes::new(base_fee, blob_fee));
243    let mut total_fees = U256::ZERO;
244    let mut cumulative_tx_gas_used = 0u64;
245    let mut block_regular_gas_used = 0u64;
246    let mut block_state_gas_used = 0u64;
247    // initialize empty blob sidecars at first. If cancun is active then this will be populated by
248    // blob sidecars if any.
249    let mut blob_sidecars = BlobSidecars::Empty;
250    let mut block_blob_count = 0;
251    let mut block_transactions_rlp_length = 0;
252    let protocol_max_blob_count =
253        blob_params.as_ref().map(|params| params.max_blob_count).unwrap_or_default();
254    // Apply user-configured blob limit (EIP-7872)
255    // Per EIP-7872: if the minimum is zero, set it to one
256    let max_blob_count = builder_config
257        .max_blobs_per_block
258        .map(|user_limit| std::cmp::min(user_limit, protocol_max_blob_count).max(1))
259        .unwrap_or(protocol_max_blob_count);
260    let is_osaka = chain_spec.is_osaka_active_at_timestamp(attributes.timestamp());
261    let withdrawals_rlp_length =
262        attributes.withdrawals.as_ref().map(|withdrawals| withdrawals.length()).unwrap_or(0);
263
264    while let Some(pool_tx) = best_txs.next() {
265        // check if the job was cancelled, if so we can exit early
266        if cancel.is_cancelled() {
267            return Ok(BuildOutcome::Cancelled)
268        }
269
270        // ensure we still have capacity for this transaction
271        let exceeds_gas_limit = if separate_block_gas {
272            let regular_available = gas_limit.saturating_sub(block_regular_gas_used);
273            let state_available = gas_limit.saturating_sub(block_state_gas_used);
274            let regular_limit = pool_tx.gas_limit().min(regular_gas_limit_cap);
275            if regular_limit > regular_available {
276                Some((regular_limit, regular_available))
277            } else if pool_tx.gas_limit() > state_available {
278                Some((pool_tx.gas_limit(), state_available))
279            } else {
280                None
281            }
282        } else {
283            let available = gas_limit.saturating_sub(cumulative_tx_gas_used);
284            (pool_tx.gas_limit() > available).then_some((pool_tx.gas_limit(), available))
285        };
286        if let Some((transaction_gas_limit, block_available_gas)) = exceeds_gas_limit {
287            // we can't fit this transaction into the block, so we need to mark it as invalid
288            // which also removes all dependent transaction from the iterator before we can
289            // continue
290            best_txs.mark_invalid(
291                &pool_tx,
292                InvalidPoolTransactionError::ExceedsGasLimit(
293                    transaction_gas_limit,
294                    block_available_gas,
295                ),
296            );
297            continue
298        }
299
300        // convert tx to a signed transaction
301        let tx = pool_tx.to_consensus();
302        let tx_rlp_len = tx.inner().length();
303        let estimated_block_size_with_tx =
304            block_transactions_rlp_length + tx_rlp_len + withdrawals_rlp_length + 1024; // 1Kb of overhead for the block header
305
306        if is_osaka && estimated_block_size_with_tx > MAX_RLP_BLOCK_SIZE {
307            best_txs.mark_invalid(
308                &pool_tx,
309                InvalidPoolTransactionError::OversizedData {
310                    size: estimated_block_size_with_tx,
311                    limit: MAX_RLP_BLOCK_SIZE,
312                },
313            );
314            continue
315        }
316
317        // There's only limited amount of blob space available per block, so we need to check if
318        // the EIP-4844 can still fit in the block
319        let mut blob_tx_sidecar = None;
320        let tx_blob_count = tx.blob_count();
321        if let Some(tx_blob_count) = tx_blob_count {
322            if block_blob_count + tx_blob_count > max_blob_count {
323                // we can't fit this _blob_ transaction into the block, so we mark it as
324                // invalid, which removes its dependent transactions from
325                // the iterator. This is similar to the gas limit condition
326                // for regular transactions above.
327                trace!(
328                    target: "payload_builder",
329                    tx = ?tx.hash(),
330                    ?block_blob_count,
331                    "skipping blob transaction because it would exceed the max blob count per block"
332                );
333                best_txs.mark_invalid(
334                    &pool_tx,
335                    InvalidPoolTransactionError::Eip4844(
336                        Eip4844PoolTransactionError::TooManyEip4844Blobs {
337                            have: block_blob_count + tx_blob_count,
338                            permitted: max_blob_count,
339                        },
340                    ),
341                );
342                continue
343            }
344
345            let blob_sidecar_result = 'sidecar: {
346                let Some(sidecar) =
347                    pool.get_blob(*tx.hash()).map_err(PayloadBuilderError::other)?
348                else {
349                    break 'sidecar Err(Eip4844PoolTransactionError::MissingEip4844BlobSidecar)
350                };
351
352                if is_osaka {
353                    if sidecar.is_eip7594() {
354                        Ok(sidecar)
355                    } else {
356                        Err(Eip4844PoolTransactionError::UnexpectedEip4844SidecarAfterOsaka)
357                    }
358                } else if sidecar.is_eip4844() {
359                    Ok(sidecar)
360                } else {
361                    Err(Eip4844PoolTransactionError::UnexpectedEip7594SidecarBeforeOsaka)
362                }
363            };
364
365            blob_tx_sidecar = match blob_sidecar_result {
366                Ok(sidecar) => Some(sidecar),
367                Err(error) => {
368                    best_txs.mark_invalid(&pool_tx, InvalidPoolTransactionError::Eip4844(error));
369                    continue
370                }
371            };
372        }
373
374        let miner_fee = tx.effective_tip_per_gas(base_fee);
375        let tx_hash = *tx.tx_hash();
376        let gas_output = match builder.execute_transaction(tx) {
377            Ok(gas_output) => gas_output,
378            Err(BlockExecutionError::Validation(BlockValidationError::InvalidTx {
379                error, ..
380            })) => {
381                if error.is_nonce_too_low() {
382                    // if the nonce is too low, we can skip this transaction
383                    trace!(target: "payload_builder", %error, ?tx_hash, "skipping nonce too low transaction");
384                } else {
385                    // if the transaction is invalid, we can skip it and all of its
386                    // descendants
387                    trace!(target: "payload_builder", %error, ?tx_hash, "skipping invalid transaction and its descendants");
388                    best_txs.mark_invalid(
389                        &pool_tx,
390                        InvalidPoolTransactionError::Consensus(
391                            InvalidTransactionError::TxTypeNotSupported,
392                        ),
393                    );
394                }
395                continue
396            }
397            // The executor is the source of truth for block gas availability. Keep this
398            // non-fatal in case local builder accounting diverges from executor rules.
399            Err(BlockExecutionError::Validation(
400                BlockValidationError::TransactionGasLimitMoreThanAvailableBlockGas {
401                    transaction_gas_limit,
402                    block_available_gas,
403                },
404            )) => {
405                trace!(target: "payload_builder", %transaction_gas_limit, %block_available_gas, ?tx_hash, "skipping transaction exceeding block gas limit");
406                best_txs.mark_invalid(
407                    &pool_tx,
408                    InvalidPoolTransactionError::ExceedsGasLimit(
409                        transaction_gas_limit,
410                        block_available_gas,
411                    ),
412                );
413                continue
414            }
415            // this is an error that we should treat as fatal for this attempt
416            Err(err) => return Err(PayloadBuilderError::evm(err)),
417        };
418
419        // add to the total blob gas used if the transaction successfully executed
420        if let Some(blob_count) = tx_blob_count {
421            block_blob_count += blob_count;
422
423            // if we've reached the max blob count, we can skip blob txs entirely
424            if block_blob_count == max_blob_count {
425                best_txs.skip_blobs();
426            }
427        }
428
429        block_transactions_rlp_length += tx_rlp_len;
430
431        // update and add to total fees
432        let gas_used = gas_output.tx_gas_used();
433        let miner_fee = miner_fee.expect("fee is always valid; execution succeeded");
434        total_fees += U256::from(miner_fee) * U256::from(gas_used);
435        cumulative_tx_gas_used += gas_used;
436        block_regular_gas_used += gas_output.regular_gas_used();
437        block_state_gas_used += gas_output.state_gas_used();
438
439        // Add blob tx sidecar to the payload.
440        if let Some(sidecar) = blob_tx_sidecar {
441            blob_sidecars.push_sidecar_variant(sidecar.as_ref().clone());
442        }
443    }
444
445    // check if we have a better block
446    if !is_better_payload(best_payload.as_ref(), total_fees) {
447        // Release db
448        drop(builder);
449        // can skip building the block
450        return Ok(BuildOutcome::Aborted { fees: total_fees, cached_reads })
451    }
452
453    let BlockBuilderOutcome { execution_result, block, block_access_list, .. } = if skip_state_root
454    {
455        debug!(
456            target: "payload_builder",
457            id = %payload_id,
458            state_root = ?parent_header.state_root,
459            "skipping payload state-root computation"
460        );
461        builder.finish(
462            state_provider.as_ref(),
463            Some((parent_header.state_root, TrieUpdates::default())),
464        )?
465    } else if use_state_root_task {
466        let mut task = state_root_handle.expect("state-root task exists if hook was installed");
467        // The state-root task has been computing incrementally alongside tx execution.
468        // This recv() waits for the final root hash — most work is already done.
469        // Fall back to sync state root if the trie pipeline fails.
470        builder.finish_with_state_root(state_provider.as_ref(), |_| match task.state_root() {
471            Ok(outcome) => {
472                debug!(
473                    target: "payload_builder",
474                    id = %payload_id,
475                    state_root = ?outcome.state_root,
476                    job = task.name(),
477                    "received state root from state-root job"
478                );
479                Ok(Some((outcome.state_root, Arc::unwrap_or_clone(outcome.trie_updates))))
480            }
481            Err(err) => {
482                warn!(target: "payload_builder", id = %payload_id, %err, "state-root job failed, falling back to sync state root");
483                Ok(None)
484            }
485        })?
486    } else {
487        builder.finish(state_provider.as_ref(), None)?
488    };
489
490    let requests = chain_spec
491        .is_prague_active_at_timestamp(attributes.timestamp())
492        .then_some(execution_result.requests);
493
494    debug!(
495        target: "payload_builder",
496        id = %payload_id,
497        sealed_block_header = ?block.sealed_header(),
498        "sealed built block"
499    );
500
501    if is_osaka && block.rlp_length() > MAX_RLP_BLOCK_SIZE {
502        return Err(PayloadBuilderError::other(ConsensusError::BlockTooLarge {
503            rlp_length: block.rlp_length(),
504            max_rlp_length: MAX_RLP_BLOCK_SIZE,
505        }));
506    }
507
508    let block_access_list = block_access_list.map(|bal| bal.split().1);
509    let payload = EthBuiltPayload::new(Arc::new(block), total_fees, requests, block_access_list)
510        // add blob sidecars from the executed txs
511        .with_sidecars(blob_sidecars);
512
513    Ok(BuildOutcome::Better { payload, cached_reads })
514}