Skip to main content

tempo_payload_builder/
lib.rs

1//! Tempo Payload Builder.
2
3#![cfg_attr(not(test), warn(unused_crate_dependencies))]
4#![cfg_attr(docsrs, feature(doc_cfg))]
5
6mod metrics;
7
8use crate::metrics::{InstrumentedFinishProvider, TempoPayloadBuilderMetrics};
9use alloy_consensus::{BlockHeader as _, Signed, Transaction, TxLegacy};
10use alloy_primitives::{Address, U256};
11use alloy_rlp::{Decodable, Encodable};
12use reth_basic_payload_builder::{
13    BuildArguments, BuildOutcome, MissingPayloadBehaviour, PayloadBuilder, PayloadConfig,
14    is_better_payload,
15};
16use reth_chainspec::{ChainSpecProvider, EthChainSpec, EthereumHardforks};
17use reth_consensus_common::validation::MAX_RLP_BLOCK_SIZE;
18use reth_engine_tree::tree::instrumented_state::InstrumentedStateProvider;
19use reth_errors::{ConsensusError, ProviderError};
20use reth_evm::{
21    ConfigureEvm, Database, Evm, NextBlockEnvAttributes,
22    block::{BlockExecutionError, BlockExecutor, BlockValidationError, TxResult},
23    execute::{BlockBuilder, BlockBuilderOutcome},
24};
25use reth_execution_types::BlockExecutionOutput;
26use reth_payload_builder::{EthBuiltPayload, PayloadBuilderError};
27use reth_payload_primitives::{BuiltPayload, BuiltPayloadExecutedBlock};
28use reth_primitives_traits::{Recovered, transaction::error::InvalidTransactionError};
29use reth_revm::{State, context::Block, database::StateProviderDatabase};
30use reth_storage_api::{StateProvider, StateProviderFactory};
31use reth_transaction_pool::{
32    BestTransactions, BestTransactionsAttributes, TransactionPool, ValidPoolTransaction,
33    error::InvalidPoolTransactionError,
34};
35use std::{
36    sync::{
37        Arc,
38        atomic::{AtomicU64, Ordering},
39    },
40    time::{Duration, Instant},
41};
42use tempo_chainspec::{TempoChainSpec, hardfork::TempoHardforks};
43use tempo_consensus::TEMPO_SHARED_GAS_DIVISOR;
44use tempo_evm::{TempoEvmConfig, TempoNextBlockEnvAttributes, TempoStateAccess, evm::TempoEvm};
45use tempo_payload_types::{TempoBuiltPayload, TempoPayloadAttributes};
46use tempo_precompiles::{tip_fee_manager::TipFeeManager, validator_config_v2::ValidatorConfigV2};
47use tempo_primitives::{
48    RecoveredSubBlock, SubBlockMetadata, TempoHeader, TempoTxEnvelope,
49    subblock::PartialValidatorKey,
50    transaction::{
51        calc_gas_balance_spending,
52        envelope::{TEMPO_SYSTEM_TX_SENDER, TEMPO_SYSTEM_TX_SIGNATURE},
53    },
54};
55use tempo_transaction_pool::{
56    StateAwareBestTransactions, TempoTransactionPool,
57    transaction::{TempoPoolTransactionError, TempoPooledTransaction},
58};
59use tracing::{Level, debug, debug_span, error, info, instrument, trace, warn};
60
61/// Returns true if a subblock has any expired transactions for the given timestamp.
62fn has_expired_transactions(subblock: &RecoveredSubBlock, timestamp: u64) -> bool {
63    subblock.transactions.iter().any(|tx| {
64        tx.as_aa().is_some_and(|tx| {
65            tx.tx()
66                .valid_before
67                .is_some_and(|valid| valid.get() <= timestamp)
68        })
69    })
70}
71
72#[derive(Debug, Clone)]
73pub struct TempoPayloadBuilder<Provider> {
74    pool: TempoTransactionPool<Provider>,
75    provider: Provider,
76    evm_config: TempoEvmConfig,
77    metrics: TempoPayloadBuilderMetrics,
78    /// Height at which we've seen an invalid subblock.
79    ///
80    /// We pre-validate all of the subblock transactions when collecting subblocks, so this
81    /// should never be set because subblocks with invalid transactions should never make it to the payload builder.
82    ///
83    /// However, due to disruptive nature of subblock-related bugs (invalid subblock
84    /// we're continuously failing to apply halts block building), we protect against this by tracking
85    /// last height at which we've seen an invalid subblock, and not including any subblocks
86    /// at this height for any payloads.
87    highest_invalid_subblock: Arc<AtomicU64>,
88    /// Whether the node is configured in `--dev` miner mode.
89    is_dev: bool,
90    /// Whether to enable state provider metrics.
91    state_provider_metrics: bool,
92    /// Whether to disable state cache.
93    disable_state_cache: bool,
94}
95
96impl<Provider> TempoPayloadBuilder<Provider> {
97    pub fn new(
98        pool: TempoTransactionPool<Provider>,
99        provider: Provider,
100        evm_config: TempoEvmConfig,
101        is_dev: bool,
102        state_provider_metrics: bool,
103        disable_state_cache: bool,
104    ) -> Self {
105        Self {
106            pool,
107            provider,
108            evm_config,
109            metrics: TempoPayloadBuilderMetrics::default(),
110            highest_invalid_subblock: Default::default(),
111            is_dev,
112            state_provider_metrics,
113            disable_state_cache,
114        }
115    }
116}
117
118impl<Provider: ChainSpecProvider<ChainSpec = TempoChainSpec>> TempoPayloadBuilder<Provider> {
119    /// Builds system transactions to seal the block.
120    ///
121    /// Returns a vector of system transactions that must be executed at the end of each block:
122    /// - Subblocks signatures - validates subblock signatures
123    fn build_seal_block_txs(
124        &self,
125        evm: &TempoEvm<impl Database>,
126        subblocks: &[RecoveredSubBlock],
127    ) -> Vec<Recovered<TempoTxEnvelope>> {
128        if subblocks.is_empty() && evm.cfg.spec.is_t4() {
129            // Post-T4, omit the subblocks metadata transaction if there are no subblocks
130            return vec![];
131        }
132
133        let chain_spec = self.provider.chain_spec();
134        let chain_id = Some(chain_spec.chain().id());
135
136        // Build subblocks signatures system transaction
137        let subblocks_metadata = subblocks
138            .iter()
139            .map(|s| s.metadata())
140            .collect::<Vec<SubBlockMetadata>>();
141        let subblocks_input = alloy_rlp::encode(&subblocks_metadata)
142            .into_iter()
143            .chain(evm.block.number.to_be_bytes_vec())
144            .collect();
145
146        let subblocks_signatures_tx = Recovered::new_unchecked(
147            TempoTxEnvelope::Legacy(Signed::new_unhashed(
148                TxLegacy {
149                    chain_id,
150                    nonce: 0,
151                    gas_price: 0,
152                    gas_limit: 0,
153                    to: Address::ZERO.into(),
154                    value: U256::ZERO,
155                    input: subblocks_input,
156                },
157                TEMPO_SYSTEM_TX_SIGNATURE,
158            )),
159            TEMPO_SYSTEM_TX_SENDER,
160        );
161
162        vec![subblocks_signatures_tx]
163    }
164}
165
166impl<Provider> PayloadBuilder for TempoPayloadBuilder<Provider>
167where
168    Provider:
169        StateProviderFactory + ChainSpecProvider<ChainSpec = TempoChainSpec> + Clone + 'static,
170{
171    type Attributes = TempoPayloadAttributes;
172    type BuiltPayload = TempoBuiltPayload;
173
174    fn try_build(
175        &self,
176        args: BuildArguments<Self::Attributes, Self::BuiltPayload>,
177    ) -> Result<BuildOutcome<Self::BuiltPayload>, PayloadBuilderError> {
178        self.build_payload(
179            args,
180            |attributes| self.pool.best_transactions_with_attributes(attributes),
181            false,
182        )
183    }
184
185    fn on_missing_payload(
186        &self,
187        _args: BuildArguments<Self::Attributes, Self::BuiltPayload>,
188    ) -> MissingPayloadBehaviour<Self::BuiltPayload> {
189        MissingPayloadBehaviour::AwaitInProgress
190    }
191
192    fn build_empty_payload(
193        &self,
194        config: PayloadConfig<Self::Attributes, TempoHeader>,
195    ) -> Result<Self::BuiltPayload, PayloadBuilderError> {
196        self.build_payload(
197            BuildArguments::new(
198                Default::default(),
199                None,
200                None,
201                config,
202                Default::default(),
203                Default::default(),
204            ),
205            |_| core::iter::empty(),
206            true,
207        )?
208        .into_payload()
209        .ok_or_else(|| PayloadBuilderError::MissingPayload)
210    }
211}
212
213impl<Provider> TempoPayloadBuilder<Provider>
214where
215    Provider: StateProviderFactory + ChainSpecProvider<ChainSpec = TempoChainSpec>,
216{
217    #[instrument(
218        target = "payload_builder",
219        skip_all,
220        fields(
221            id = %args.config.payload_id,
222            parent_number = %args.config.parent_header.number(),
223            parent_hash = %args.config.parent_header.hash()
224        )
225    )]
226    fn build_payload<Txs>(
227        &self,
228        args: BuildArguments<TempoPayloadAttributes, TempoBuiltPayload>,
229        best_txs: impl FnOnce(BestTransactionsAttributes) -> Txs,
230        empty: bool,
231    ) -> Result<BuildOutcome<TempoBuiltPayload>, PayloadBuilderError>
232    where
233        Txs: BestTransactions<Item = Arc<ValidPoolTransaction<TempoPooledTransaction>>>,
234    {
235        let BuildArguments {
236            mut cached_reads,
237            trie_handle,
238            config,
239            cancel,
240            best_payload,
241            ..
242        } = args;
243        let PayloadConfig {
244            parent_header,
245            attributes,
246            payload_id,
247        } = config;
248        let build_until_interrupt =
249            // When trie handle is provided, we only build the payload once, until the interrupt is triggered
250            trie_handle.is_some()
251            // `--dev` mode doesn't have payload building interrupts
252            && !self.is_dev;
253
254        macro_rules! check_cancel {
255            () => {
256                if cancel.is_cancelled() {
257                    return Ok(BuildOutcome::Cancelled);
258                }
259            };
260        }
261
262        check_cancel!();
263
264        let start = Instant::now();
265
266        let block_time_millis =
267            (attributes.timestamp_millis() - parent_header.timestamp_millis()) as f64;
268        self.metrics.block_time_millis.record(block_time_millis);
269        self.metrics.block_time_millis_last.set(block_time_millis);
270
271        let state_setup_start = Instant::now();
272        let _state_setup_span = debug_span!(target: "payload_builder", "state_setup").entered();
273        let state_provider = self.provider.state_by_block_hash(parent_header.hash())?;
274        let state_provider: Box<dyn StateProvider> = if self.state_provider_metrics {
275            Box::new(InstrumentedStateProvider::new(state_provider, "builder"))
276        } else {
277            state_provider
278        };
279        let state = StateProviderDatabase::new(&state_provider);
280        let mut db = State::builder()
281            .with_database(if self.disable_state_cache {
282                Box::new(state) as Box<dyn Database<Error = ProviderError>>
283            } else {
284                Box::new(cached_reads.as_db_mut(state))
285            })
286            .with_bundle_update()
287            .build();
288        drop(_state_setup_span);
289        self.metrics
290            .state_setup_duration_seconds
291            .record(state_setup_start.elapsed());
292
293        check_cancel!();
294
295        let chain_spec = self.provider.chain_spec();
296        let is_osaka = self
297            .provider
298            .chain_spec()
299            .is_osaka_active_at_timestamp(attributes.timestamp);
300
301        let block_gas_limit: u64 = parent_header.gas_limit();
302        let shared_gas_limit = block_gas_limit / TEMPO_SHARED_GAS_DIVISOR;
303        // Non-shared gas limit is the maximum gas available for proposer's pool transactions.
304        // The remaining `shared_gas_limit` is reserved for validator subblocks.
305        let non_shared_gas_limit = block_gas_limit - shared_gas_limit;
306        let general_gas_limit = chain_spec.general_gas_limit_at(
307            attributes.timestamp,
308            block_gas_limit,
309            shared_gas_limit,
310        );
311
312        let mut cumulative_gas_used = 0;
313        let mut cumulative_state_gas_used = 0u64;
314        let mut non_payment_gas_used = 0;
315        // initial block size usage - size of withdrawals plus 1Kb of overhead for the block header
316        let mut block_size_used = attributes
317            .withdrawals
318            .as_ref()
319            .map(|w| w.length())
320            .unwrap_or(0)
321            + 1024;
322        let mut payment_transactions = 0u64;
323        let mut total_fees = U256::ZERO;
324
325        // If building an empty payload, don't include any subblocks
326        //
327        // Also don't include any subblocks if we've seen an invalid subblock
328        // at this height or above.
329        let mut subblocks = if empty
330            || self.highest_invalid_subblock.load(Ordering::Relaxed) > parent_header.number()
331        {
332            vec![]
333        } else {
334            attributes.subblocks()
335        };
336
337        subblocks.retain(|subblock| {
338            // Edge case: remove subblocks with expired transactions
339            //
340            // We pre-validate all of the subblocks on top of parent state in subblocks service
341            // which leaves the only reason for transactions to get invalidated by expiry of
342            // `valid_before` field.
343            if has_expired_transactions(subblock, attributes.timestamp) {
344                self.metrics.inc_subblocks_expired();
345                return false;
346            }
347
348            // Account for the subblock's size
349            block_size_used += subblock.total_tx_size();
350
351            true
352        });
353
354        let subblock_fee_recipients = subblocks
355            .iter()
356            .map(|subblock| {
357                (
358                    PartialValidatorKey::from_slice(&subblock.validator()[..15]),
359                    subblock.fee_recipient,
360                )
361            })
362            .collect();
363
364        let mut builder = self
365            .evm_config
366            .builder_for_next_block(
367                &mut db,
368                &parent_header,
369                TempoNextBlockEnvAttributes {
370                    inner: NextBlockEnvAttributes {
371                        timestamp: attributes.timestamp,
372                        suggested_fee_recipient: attributes.suggested_fee_recipient,
373                        prev_randao: attributes.prev_randao,
374                        gas_limit: block_gas_limit,
375                        parent_beacon_block_root: attributes.parent_beacon_block_root,
376                        withdrawals: attributes.withdrawals.clone().map(Into::into),
377                        extra_data: attributes.extra_data().clone(),
378                        slot_number: attributes.slot_number,
379                    },
380                    general_gas_limit,
381                    shared_gas_limit,
382                    timestamp_millis_part: attributes.timestamp_millis_part(),
383                    consensus_context: attributes.consensus_context(),
384                    subblock_fee_recipients,
385                },
386            )
387            .map_err(PayloadBuilderError::other)?;
388
389        check_cancel!();
390
391        // Override the fee recipient with the on-chain value from the V2
392        // validator config contract, if available.
393        maybe_override_fee_recipient(&mut builder, &attributes);
394
395        if let Some(ref handle) = trie_handle {
396            builder
397                .executor_mut()
398                .set_state_hook(Some(Box::new(handle.state_hook())));
399        }
400
401        builder.apply_pre_execution_changes().map_err(|err| {
402            warn!(%err, "failed to apply pre-execution changes");
403            PayloadBuilderError::Internal(err.into())
404        })?;
405
406        check_cancel!();
407
408        debug!("building new payload");
409
410        // Prepare system transactions before actual block building and account for their size.
411        let prepare_system_txs_start = Instant::now();
412        let system_txs = self.build_seal_block_txs(builder.evm(), &subblocks);
413        for tx in &system_txs {
414            block_size_used += tx.inner().length();
415        }
416        let prepare_system_txs_elapsed = prepare_system_txs_start.elapsed();
417        self.metrics
418            .prepare_system_transactions_duration_seconds
419            .record(prepare_system_txs_elapsed);
420
421        let base_fee = builder.evm_mut().block().basefee;
422        let validator_fee_token = resolve_validator_fee_token(&mut builder)?;
423        let pool_fetch_start = Instant::now();
424        // Wrap best transactions into state-aware wrapper to skip transactions that
425        // get invalidated by already-executed ones.
426        let mut best_txs =
427            StateAwareBestTransactions::new(best_txs(BestTransactionsAttributes::new(
428                base_fee,
429                builder
430                    .evm_mut()
431                    .block()
432                    .blob_gasprice()
433                    .map(|gasprice| gasprice as u64),
434            )));
435        self.metrics
436            .pool_fetch_duration_seconds
437            .record(pool_fetch_start.elapsed());
438
439        let execution_start = Instant::now();
440        let _block_fill_span = debug_span!(target: "payload_builder", "block_fill").entered();
441        loop {
442            if attributes.is_interrupted() {
443                break;
444            }
445
446            check_cancel!();
447
448            let Some(pool_tx) = best_txs.next() else {
449                if build_until_interrupt && cumulative_gas_used < non_shared_gas_limit {
450                    std::thread::sleep(Duration::from_millis(1));
451                    continue;
452                }
453                break;
454            };
455
456            let max_regular_gas_used = core::cmp::min(
457                pool_tx.gas_limit(),
458                builder.evm().cfg.tx_gas_limit_cap.unwrap_or(u64::MAX),
459            );
460
461            // Ensure we still have capacity for this transaction within the non-shared gas limit.
462            // The remaining `shared_gas_limit` is reserved for validator subblocks and must not
463            // be consumed by proposer's pool transactions.
464            if cumulative_gas_used + max_regular_gas_used > non_shared_gas_limit {
465                // Mark this transaction as invalid since it doesn't fit
466                // The iterator will handle lane switching internally when appropriate
467                best_txs.mark_invalid(
468                    &pool_tx,
469                    &InvalidPoolTransactionError::ExceedsGasLimit(
470                        pool_tx.gas_limit(),
471                        non_shared_gas_limit - cumulative_gas_used,
472                    ),
473                );
474                self.metrics
475                    .inc_pool_tx_skipped("exceeds_non_shared_gas_limit");
476                continue;
477            }
478
479            // If the tx is not a payment and will exceed the general gas limit
480            // mark the tx as invalid and continue
481            if !pool_tx.transaction.is_payment()
482                && non_payment_gas_used + max_regular_gas_used > general_gas_limit
483            {
484                best_txs.mark_invalid(
485                    &pool_tx,
486                    &InvalidPoolTransactionError::Other(Box::new(
487                        TempoPoolTransactionError::ExceedsNonPaymentLimit,
488                    )),
489                );
490                self.metrics
491                    .inc_pool_tx_skipped("exceeds_general_gas_limit");
492                continue;
493            }
494
495            // check if the job was interrupted, if so we can skip remaining transactions
496            if attributes.is_interrupted() {
497                break;
498            }
499
500            check_cancel!();
501            let is_payment = pool_tx.transaction.is_payment();
502            if is_payment {
503                payment_transactions += 1;
504            }
505
506            let tx_rlp_length = pool_tx.transaction.inner().length();
507            let estimated_block_size_with_tx = block_size_used + tx_rlp_length;
508
509            if is_osaka && estimated_block_size_with_tx > MAX_RLP_BLOCK_SIZE {
510                best_txs.mark_invalid(
511                    &pool_tx,
512                    &InvalidPoolTransactionError::OversizedData {
513                        size: estimated_block_size_with_tx,
514                        limit: MAX_RLP_BLOCK_SIZE,
515                    },
516                );
517                self.metrics.inc_pool_tx_skipped("oversized_block");
518                continue;
519            }
520
521            let effective_gas_price = pool_tx.transaction.effective_gas_price(Some(base_fee));
522
523            let tx_debug_repr = tracing::enabled!(Level::TRACE)
524                .then(|| format!("{:?}", pool_tx.transaction))
525                .unwrap_or_default();
526
527            let tx_with_env = pool_tx.transaction.clone().into_with_tx_env();
528            let tx_execution_start = Instant::now();
529            if let Err(err) =
530                builder.execute_transaction_with_result_closure(tx_with_env, |result| {
531                    cumulative_gas_used += result.block_gas_used();
532                    cumulative_state_gas_used += result.state_gas_used();
533                    if !is_payment {
534                        non_payment_gas_used += result.block_gas_used();
535                    }
536
537                    // Score payload value by actual validator payout, applying the AMM
538                    // haircut when the transaction's fee token differs from the validator's.
539                    let nominal_spending = calc_gas_balance_spending(
540                        result.result().result.tx_gas_used(),
541                        effective_gas_price,
542                    );
543                    if let Some(fee_token) = pool_tx.transaction.resolved_fee_token() {
544                        if fee_token == validator_fee_token {
545                            total_fees += nominal_spending;
546                        } else {
547                            total_fees +=
548                                tempo_precompiles::tip_fee_manager::amm::compute_amount_out(
549                                    nominal_spending,
550                                )
551                                .expect(
552                                    "execution succeeded, so compute_amount_out should not fail",
553                                );
554                        }
555                    } else {
556                        warn!("no resolved fee token for a pool transaction")
557                    }
558
559                    // Notify transactions iterator about the new state.
560                    best_txs.on_new_result(result);
561                })
562            {
563                if let BlockExecutionError::Validation(BlockValidationError::InvalidTx {
564                    error,
565                    ..
566                }) = &err
567                {
568                    if error.is_nonce_too_low() {
569                        // if the nonce is too low, we can skip this transaction
570                        trace!(%error, tx = %tx_debug_repr, "skipping nonce too low transaction");
571                        self.metrics.inc_pool_tx_skipped("nonce_too_low");
572                    } else {
573                        // if the transaction is invalid, we can skip it and all of its
574                        // descendants
575                        trace!(%error, tx = %tx_debug_repr, "skipping invalid transaction and its descendants");
576                        best_txs.mark_invalid(
577                            &pool_tx,
578                            &InvalidPoolTransactionError::Consensus(
579                                InvalidTransactionError::TxTypeNotSupported,
580                            ),
581                        );
582                        self.metrics.inc_pool_tx_skipped("invalid_tx");
583                    }
584                    continue;
585                } else {
586                    return Err(PayloadBuilderError::evm(err));
587                }
588            };
589            let elapsed = tx_execution_start.elapsed();
590            self.metrics
591                .transaction_execution_duration_seconds
592                .record(elapsed);
593            trace!(?elapsed, "Transaction executed");
594
595            block_size_used += tx_rlp_length;
596        }
597        drop(_block_fill_span);
598        let total_normal_transaction_execution_elapsed = execution_start.elapsed();
599        self.metrics
600            .total_normal_transaction_execution_duration_seconds
601            .record(total_normal_transaction_execution_elapsed);
602        self.metrics
603            .payment_transactions
604            .record(payment_transactions as f64);
605        self.metrics
606            .payment_transactions_last
607            .set(payment_transactions as f64);
608
609        check_cancel!();
610
611        // check if we have a better block or received more subblocks
612        if !is_better_payload(best_payload.as_ref(), total_fees)
613            && !is_more_subblocks(best_payload.as_ref(), &subblocks)
614        {
615            // Release db
616            drop(builder);
617            drop(db);
618            // can skip building the block
619            return Ok(BuildOutcome::Aborted {
620                fees: total_fees,
621                cached_reads,
622            });
623        }
624
625        let subblocks_start = Instant::now();
626        let _subblock_txs_span =
627            debug_span!(target: "payload_builder", "execute_subblock_txs").entered();
628        let subblocks_count = subblocks.len() as f64;
629        let mut subblock_transactions = 0f64;
630        // Apply subblock transactions
631        for subblock in &subblocks {
632            let subblock_start = Instant::now();
633            let mut subblock_tx_count = 0f64;
634
635            for tx in subblock.transactions_recovered() {
636                if let Err(err) = builder.execute_transaction(tx.cloned()) {
637                    if let BlockExecutionError::Validation(BlockValidationError::InvalidTx {
638                        ..
639                    }) = &err
640                    {
641                        error!(
642                            ?err,
643                            "subblock transaction failed execution, aborting payload building"
644                        );
645                        self.highest_invalid_subblock
646                            .store(builder.evm().block().number.to(), Ordering::Relaxed);
647                        self.metrics.inc_build_failure("subblock_invalid_tx");
648                        return Err(PayloadBuilderError::evm(err));
649                    } else {
650                        return Err(PayloadBuilderError::evm(err));
651                    }
652                }
653
654                subblock_tx_count += 1.0;
655            }
656
657            self.metrics
658                .subblock_execution_duration_seconds
659                .record(subblock_start.elapsed());
660            self.metrics
661                .subblock_transaction_count
662                .record(subblock_tx_count);
663            subblock_transactions += subblock_tx_count;
664        }
665        drop(_subblock_txs_span);
666        let total_subblock_transaction_execution_elapsed = subblocks_start.elapsed();
667        self.metrics
668            .total_subblock_transaction_execution_duration_seconds
669            .record(total_subblock_transaction_execution_elapsed);
670        self.metrics.subblocks.record(subblocks_count);
671        self.metrics.subblocks_last.set(subblocks_count);
672        self.metrics
673            .subblock_transactions
674            .record(subblock_transactions);
675        self.metrics
676            .subblock_transactions_last
677            .set(subblock_transactions);
678
679        // Apply system transactions
680        let system_txs_execution_start = Instant::now();
681        let _system_txs_span =
682            debug_span!(target: "payload_builder", "execute_system_txs").entered();
683        for system_tx in system_txs {
684            builder
685                .execute_transaction(system_tx)
686                .map_err(PayloadBuilderError::evm)?;
687        }
688        drop(_system_txs_span);
689        let system_txs_execution_elapsed = system_txs_execution_start.elapsed();
690        self.metrics
691            .system_transactions_execution_duration_seconds
692            .record(system_txs_execution_elapsed);
693
694        let total_transaction_execution_elapsed = execution_start.elapsed();
695        self.metrics
696            .total_transaction_execution_duration_seconds
697            .record(total_transaction_execution_elapsed);
698
699        let builder_finish_start = Instant::now();
700        let _finish_span = debug_span!(target: "payload_builder", "finish_block").entered();
701        let finish_provider = || InstrumentedFinishProvider {
702            inner: &*state_provider,
703            metrics: self.metrics.clone(),
704        };
705
706        check_cancel!();
707
708        let BlockBuilderOutcome {
709            execution_result,
710            block,
711            hashed_state,
712            trie_updates,
713        } = if let Some(mut handle) = trie_handle {
714            // Dropping the hook signals that execution is complete and the sparse trie task can
715            // finalize the state root it has been updating incrementally.
716            builder.executor_mut().set_state_hook(None);
717
718            match handle.state_root() {
719                Ok(outcome) => {
720                    debug!(
721                        target: "payload_builder",
722                        id = %payload_id,
723                        state_root = ?outcome.state_root,
724                        "received state root from sparse trie"
725                    );
726                    builder.finish(
727                        finish_provider(),
728                        Some((
729                            outcome.state_root,
730                            Arc::unwrap_or_clone(outcome.trie_updates),
731                        )),
732                    )?
733                }
734                Err(err) => {
735                    warn!(
736                        target: "payload_builder",
737                        id = %payload_id,
738                        %err,
739                        "sparse trie failed, falling back to sync state root"
740                    );
741                    builder.finish(finish_provider(), None)?
742                }
743            }
744        } else {
745            builder.finish(finish_provider(), None)?
746        };
747        drop(_finish_span);
748        let builder_finish_elapsed = builder_finish_start.elapsed();
749        self.metrics
750            .payload_finalization_duration_seconds
751            .record(builder_finish_elapsed);
752
753        let total_transactions = block.transaction_count();
754        self.metrics
755            .total_transactions
756            .record(total_transactions as f64);
757        self.metrics
758            .total_transactions_last
759            .set(total_transactions as f64);
760
761        let gas_used = block.gas_used();
762        self.metrics.gas_used.record(gas_used as f64);
763        self.metrics.gas_used_last.set(gas_used as f64);
764        self.metrics
765            .state_gas_used
766            .record(cumulative_state_gas_used as f64);
767        self.metrics
768            .state_gas_used_last
769            .set(cumulative_state_gas_used as f64);
770        self.metrics
771            .general_gas_used_last
772            .set(non_payment_gas_used as f64);
773        self.metrics
774            .payment_gas_used_last
775            .set(cumulative_gas_used as f64 - non_payment_gas_used as f64);
776        self.metrics
777            .general_gas_limit_last
778            .set(general_gas_limit as f64);
779        self.metrics
780            .payment_gas_limit_last
781            .set(non_shared_gas_limit as f64 - general_gas_limit as f64);
782        self.metrics
783            .shared_gas_limit_last
784            .set(shared_gas_limit as f64);
785
786        let requests = chain_spec
787            .is_prague_active_at_timestamp(attributes.timestamp)
788            .then(|| execution_result.requests.clone());
789
790        let sealed_block = Arc::new(block.sealed_block().clone());
791        let rlp_length = sealed_block.rlp_length();
792
793        if is_osaka && rlp_length > MAX_RLP_BLOCK_SIZE {
794            return Err(PayloadBuilderError::other(ConsensusError::BlockTooLarge {
795                rlp_length,
796                max_rlp_length: MAX_RLP_BLOCK_SIZE,
797            }));
798        }
799
800        let elapsed = start.elapsed();
801        self.metrics.payload_build_duration_seconds.record(elapsed);
802        let gas_per_second = sealed_block.gas_used() as f64 / elapsed.as_secs_f64();
803        self.metrics.gas_per_second.record(gas_per_second);
804        self.metrics.gas_per_second_last.set(gas_per_second);
805        self.metrics.rlp_block_size_bytes.record(rlp_length as f64);
806        self.metrics
807            .rlp_block_size_bytes_last
808            .set(rlp_length as f64);
809
810        info!(
811            parent_hash = ?sealed_block.parent_hash(),
812            number = sealed_block.number(),
813            hash = ?sealed_block.hash(),
814            timestamp = sealed_block.timestamp_millis(),
815            gas_limit = sealed_block.gas_limit(),
816            gas_used,
817            cumulative_state_gas_used,
818            extra_data = %sealed_block.extra_data(),
819            subblocks_count,
820            payment_transactions,
821            subblock_transactions,
822            total_transactions,
823            ?elapsed,
824            ?total_normal_transaction_execution_elapsed,
825            ?total_subblock_transaction_execution_elapsed,
826            ?total_transaction_execution_elapsed,
827            ?builder_finish_elapsed,
828            "Built payload"
829        );
830
831        let eth_payload = EthBuiltPayload::new(sealed_block, total_fees, requests, None);
832
833        let execution_output = BlockExecutionOutput {
834            result: execution_result,
835            state: db.take_bundle(),
836        };
837
838        let executed_block = BuiltPayloadExecutedBlock {
839            recovered_block: Arc::new(block),
840            execution_output: Arc::new(execution_output),
841            hashed_state: Arc::new(hashed_state),
842            trie_updates: Arc::new(trie_updates),
843        };
844
845        let payload = TempoBuiltPayload::new(eth_payload, Some(executed_block));
846
847        drop(db);
848        if build_until_interrupt {
849            Ok(BuildOutcome::Freeze(payload))
850        } else {
851            Ok(BuildOutcome::Better {
852                payload,
853                cached_reads,
854            })
855        }
856    }
857}
858
859pub fn is_more_subblocks(
860    best_payload: Option<&TempoBuiltPayload>,
861    subblocks: &[RecoveredSubBlock],
862) -> bool {
863    let Some(best_payload) = best_payload else {
864        return false;
865    };
866    let Some(best_metadata) = best_payload
867        .block()
868        .body()
869        .transactions
870        .iter()
871        .rev()
872        .find_map(|tx| Vec::<SubBlockMetadata>::decode(&mut tx.input().as_ref()).ok())
873    else {
874        return false;
875    };
876
877    subblocks.len() > best_metadata.len()
878}
879
880/// Overrides the block's fee recipient (beneficiary) with the value from the
881/// V2 validator config contract, if the contract is active and returns a
882/// non-zero address for the given `public_key`.
883fn maybe_override_fee_recipient<DB: Database>(
884    builder: &mut impl BlockBuilder<Executor: BlockExecutor<Evm = TempoEvm<DB>>>,
885    attributes: &TempoPayloadAttributes,
886) {
887    let Some(public_key) = attributes.proposer_public_key() else {
888        return;
889    };
890    let ctx = builder.evm_mut().ctx_mut();
891    if !ctx.cfg.spec.is_t2() {
892        return;
893    }
894
895    // We are using the database as a read-only storage context to avoid modifying the journal state.
896    // Reading slots here might be dangerous because they would end up being warmed and might affect gas accounting.
897    match ctx.journaled_state.database.with_read_only_storage_ctx(
898        ctx.cfg.spec,
899        || -> Result<Option<Address>, PayloadBuilderError> {
900            let parent_number = ctx.block.number.saturating_to::<u64>() - 1;
901
902            let config = ValidatorConfigV2::default();
903            if !config
904                .is_initialized()
905                .map_err(PayloadBuilderError::other)?
906            {
907                return Ok(None);
908            }
909            let init_height = config
910                .get_initialized_at_height()
911                .map_err(PayloadBuilderError::other)?;
912            if init_height > parent_number {
913                return Ok(None);
914            }
915            let on_chain = config
916                .validator_by_public_key(*public_key)
917                .map(|v| v.feeRecipient)
918                .map_err(PayloadBuilderError::other)?;
919            Ok((!on_chain.is_zero()).then_some(on_chain))
920        },
921    ) {
922        Ok(Some(fee_recipient)) => {
923            debug!(%fee_recipient, "resolved fee recipient from contract");
924            builder.evm_mut().ctx_mut().block.beneficiary = fee_recipient;
925        }
926        Ok(None) => {}
927        Err(err) => {
928            warn!(%err, "failed resolving fee recipient from contract; using fallback");
929        }
930    }
931}
932
933/// Resolves the validator's preferred fee token.
934fn resolve_validator_fee_token(
935    builder: &mut impl BlockBuilder<Executor: BlockExecutor<Evm = TempoEvm<impl Database>>>,
936) -> Result<Address, PayloadBuilderError> {
937    let ctx = builder.evm_mut().ctx_mut();
938    // We are using the database as a read-only storage context to avoid modifying the journal state.
939    // Reading slots here might be dangerous because they would end up being warmed and might affect gas accounting.
940    ctx.journaled_state
941        .database
942        .with_read_only_storage_ctx(ctx.cfg.spec, || {
943            TipFeeManager::new()
944                .get_validator_token(ctx.block.beneficiary)
945                .map_err(PayloadBuilderError::other)
946        })
947}
948
949#[cfg(test)]
950mod tests {
951    use super::*;
952    use alloy_consensus::BlockBody;
953    use alloy_primitives::{Address, B256, Bytes, Signature};
954    use core::num::NonZeroU64;
955    use reth_primitives_traits::SealedBlock;
956    use tempo_primitives::{
957        AASigned, Block, SignedSubBlock, SubBlock, SubBlockVersion, TempoSignature,
958        TempoTransaction,
959    };
960
961    fn nz(value: u64) -> NonZeroU64 {
962        NonZeroU64::new(value).expect("test valid_before must be non-zero")
963    }
964
965    trait TestExt {
966        fn random() -> Self;
967        fn with_valid_before(_: Option<NonZeroU64>) -> Self
968        where
969            Self: Sized,
970        {
971            Self::random()
972        }
973    }
974
975    impl TestExt for SubBlockMetadata {
976        fn random() -> Self {
977            Self {
978                version: SubBlockVersion::V1,
979                validator: B256::random(),
980                fee_recipient: Address::random(),
981                signature: Bytes::new(),
982            }
983        }
984    }
985
986    impl TestExt for RecoveredSubBlock {
987        fn random() -> Self {
988            Self::with_valid_before(None)
989        }
990
991        fn with_valid_before(valid_before: Option<NonZeroU64>) -> Self {
992            let tx = TempoTxEnvelope::AA(AASigned::new_unhashed(
993                TempoTransaction {
994                    valid_before,
995                    ..Default::default()
996                },
997                TempoSignature::default(),
998            ));
999            let signed = SignedSubBlock {
1000                inner: SubBlock {
1001                    version: SubBlockVersion::V1,
1002                    parent_hash: B256::random(),
1003                    fee_recipient: Address::random(),
1004                    transactions: vec![tx],
1005                },
1006                signature: Bytes::new(),
1007            };
1008            Self::new_unchecked(signed, vec![Address::ZERO], B256::ZERO)
1009        }
1010    }
1011
1012    fn payload_with_metadata(count: usize) -> TempoBuiltPayload {
1013        let metadata: Vec<_> = (0..count).map(|_| SubBlockMetadata::random()).collect();
1014        let input: Bytes = alloy_rlp::encode(&metadata).into();
1015        let tx = TempoTxEnvelope::Legacy(Signed::new_unhashed(
1016            TxLegacy {
1017                chain_id: None,
1018                nonce: 0,
1019                gas_price: 0,
1020                gas_limit: 0,
1021                to: Address::random().into(),
1022                value: U256::ZERO,
1023                input,
1024            },
1025            Signature::test_signature(),
1026        ));
1027        let block = Block {
1028            header: TempoHeader::default(),
1029            body: BlockBody {
1030                transactions: vec![tx],
1031                ommers: vec![],
1032                withdrawals: None,
1033            },
1034        };
1035        let sealed = Arc::new(SealedBlock::seal_slow(block));
1036        let eth = EthBuiltPayload::new(sealed, U256::ZERO, None, None);
1037        TempoBuiltPayload::new(eth, None)
1038    }
1039
1040    #[test]
1041    fn test_is_more_subblocks() {
1042        // None payload always returns false
1043        assert!(!is_more_subblocks(None, &[]));
1044        assert!(!is_more_subblocks(None, &[RecoveredSubBlock::random()]));
1045
1046        // Equal count returns false (1 == 1)
1047        let payload = payload_with_metadata(1);
1048        assert!(!is_more_subblocks(
1049            Some(&payload),
1050            &[RecoveredSubBlock::random()]
1051        ));
1052
1053        // More subblocks returns true (2 > 1)
1054        assert!(is_more_subblocks(
1055            Some(&payload),
1056            &[RecoveredSubBlock::random(), RecoveredSubBlock::random()]
1057        ));
1058
1059        // Fewer subblocks returns false (1 < 2)
1060        let payload = payload_with_metadata(2);
1061        assert!(!is_more_subblocks(
1062            Some(&payload),
1063            &[RecoveredSubBlock::random()]
1064        ));
1065
1066        // Empty metadata, empty subblocks returns false (0 > 0 is false)
1067        let payload = payload_with_metadata(0);
1068        assert!(!is_more_subblocks(Some(&payload), &[]));
1069
1070        // Empty metadata, one subblock returns true (1 > 0)
1071        assert!(is_more_subblocks(
1072            Some(&payload),
1073            &[RecoveredSubBlock::random()]
1074        ));
1075    }
1076
1077    #[test]
1078    fn test_extra_data_flow_in_attributes() {
1079        // Test that extra_data in attributes can be accessed correctly
1080        let extra_data = Bytes::from(vec![42, 43, 44, 45, 46]);
1081
1082        let attrs = TempoPayloadAttributes::new(None, 1, 0, extra_data.clone(), None, Vec::new);
1083
1084        assert_eq!(attrs.extra_data(), &extra_data);
1085
1086        // Verify the data is as expected
1087        let injected_data = attrs.extra_data().clone();
1088
1089        assert_eq!(injected_data, extra_data);
1090    }
1091
1092    #[test]
1093    fn test_has_expired_transactions_boundary() {
1094        // valid_before == timestamp → expired
1095        let subblock = RecoveredSubBlock::with_valid_before(Some(nz(1000)));
1096        assert!(has_expired_transactions(&subblock, 1000));
1097
1098        // valid_before < timestamp → expired
1099        assert!(has_expired_transactions(&subblock, 1001));
1100
1101        // valid_before > timestamp → NOT expired
1102        assert!(!has_expired_transactions(&subblock, 999));
1103
1104        // No valid_before → NOT expired
1105        let subblock_no_expiry = RecoveredSubBlock::with_valid_before(None);
1106        assert!(!has_expired_transactions(&subblock_no_expiry, 1000));
1107    }
1108}