diff --git a/crates/rbuilder/src/building/builder_tx.rs b/crates/rbuilder/src/building/builder_tx.rs new file mode 100644 index 000000000..397dabd0a --- /dev/null +++ b/crates/rbuilder/src/building/builder_tx.rs @@ -0,0 +1,493 @@ +//! Abstraction for the transactions the builder itself adds at the end of the block. +//! +//! Besides the orders it selects, a builder usually wants to append some transactions of +//! its own at the end of the block: a block signature, an attestation, a rebalance of the +//! coinbase account, and so on. [`BuilderTransactions`] is the extension point for those. +//! +//! Lifecycle, once per block: +//! 1. Before building starts, [`BuilderTransactions::validate`] and then +//! [`BuilderTransactions::reserve_block_space`] are called on the state of the parent +//! block (see [`crate::building::builders::block_building_helper::BlockBuildingHelperFromProvider::new_with_execution_tracer`]). +//! The space every source asks for is held back for the whole build, so filling the +//! block with orders can never leave the builder without room for its own txs, and the +//! value it declares in [`BuilderTransactions::reserve_coinbase_value`] comes off the +//! bid ceiling, since the builder account is what pays the proposer. +//! 2. At finalization, [`BuilderTransactions::transactions`] is called and the returned +//! txs are committed after the accumulated refunds and before the proposer payout tx +//! (see [`crate::building::PartialBlock::insert_refunds_and_proposer_payout_tx`]). +//! +//! The proposer payout tx is deliberately NOT modelled as a [`BuilderTransactions`]: it +//! has to stay the very last tx of the block (finalization, the receipts caches and the +//! backtest fee recipient detection all read it as `transactions.last()`) and its value is +//! only known once the bid is chosen, while builder txs are built once and then survive +//! every reseal of the block untouched. +//! +//! Registration is done on the building context with +//! [`BlockBuildingContext::with_builder_transactions`]. + +use super::{BlockBuildingContext, BlockState}; +use crate::building::{cached_reads::CachedDB, BlockSpace}; +use alloy_primitives::U256; +use rbuilder_primitives::TransactionSignedEcRecoveredWithBlobs; +use reth_errors::ProviderError; +use std::sync::Arc; + +/// Errors reported by a [`BuilderTransactions`] implementation. +#[derive(Debug, thiserror::Error)] +pub enum BuilderTransactionError { + /// Reading the state of the parent block failed. + #[error("Error accessing block state: {0}")] + Provider(#[from] ProviderError), + /// Signing a builder transaction failed. + #[error("Failed to sign builder transaction: {0}")] + Signing(#[from] secp256k1::Error), + /// Anything specific to an implementation. + #[error("{0}")] + Other(Box), +} + +impl BuilderTransactionError { + /// Wraps an implementation specific error. + pub fn other(error: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::Other(Box::new(error)) + } + + /// Wraps an implementation specific error message. + pub fn msg(msg: impl std::fmt::Display) -> Self { + Self::Other(msg.to_string().into()) + } +} + +/// What the builder holds back from the block for the txs it appends at the end of it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EndOfBlockReservation { + /// Block space for the proposer payout tx and every registered builder tx. + pub space: BlockSpace, + /// Value the builder txs will move out of the builder account. Subtracted from the + /// bid ceiling, since the payout to the proposer is paid from that same account. + pub value: U256, +} + +/// A source of transactions the builder appends at the end of the block, after the orders +/// and the accumulated refunds and before the proposer payout tx. +/// +/// Implementations are registered per block via +/// [`BlockBuildingContext::with_builder_transactions`] and are shared across the building +/// threads, so they must be cheap to keep around and must not carry per-block mutable +/// state. +pub trait BuilderTransactions: std::fmt::Debug + Send + Sync { + /// Name of this source, used in logs and errors. Should be stable. + fn name(&self) -> &str; + + /// Called once before block building starts, on the state of the parent block. + /// Returning an error aborts building the block, so this is the place to check that + /// whatever the source needs (a funded account, a reachable contract, a configured + /// address) is actually there instead of failing at finalization. + fn validate( + &self, + _ctx: &BlockBuildingContext, + _state: &mut BlockState, + ) -> Result<(), BuilderTransactionError> { + Ok(()) + } + + /// Block space (gas, rlp length, blob gas) to hold back for the txs this source will + /// emit. Called once, right after [`Self::validate`], on the state of the parent + /// block. + /// + /// `space_reserved_so_far` is what the proposer payout tx and the previously + /// registered sources already reserved, so an implementation that has to fit inside a + /// budget can take it into account. + /// + /// The reserved space is subtracted from the block limits while orders are simulated + /// and committed, and released just before the end of block txs are inserted. Txs + /// that do not fit in the reserved space can still be rejected at finalization, so + /// reserve for the worst case. + fn reserve_block_space( + &self, + ctx: &BlockBuildingContext, + state: &mut BlockState, + space_reserved_so_far: BlockSpace, + ) -> Result; + + /// Value this source will move out of the builder account at finalization, on top of + /// the gas its txs burn. Called once, right after [`Self::reserve_block_space`]. + /// + /// The builder account (`ctx.builder_signer`) is also the block beneficiary and is + /// what the proposer payout tx pays from, so anything a builder tx spends has to come + /// off the bid ceiling: it is subtracted from + /// [`crate::building::builders::block_building_helper::BlockBuildingHelper::true_block_value`]. + /// A source whose txs only burn gas can leave this at zero. + fn reserve_coinbase_value(&self, _ctx: &BlockBuildingContext) -> U256 { + U256::ZERO + } + + /// The txs to append, in order. Called once per block, at finalization. + /// + /// `nonce` is the next free nonce of `ctx.builder_signer`; a source returning several + /// txs must sign them with `nonce`, `nonce + 1`, ... All txs must be signed by + /// `ctx.builder_signer`, since that is the only key the builder holds, and each of + /// them must succeed: a reverted builder tx fails the whole block. + /// + /// This runs on the finalization path, which is latency critical and has no access to + /// the block state, so any state read belongs in [`Self::validate`] or + /// [`Self::reserve_block_space`]. + fn transactions( + &self, + ctx: &BlockBuildingContext, + nonce: u64, + ) -> Result, BuilderTransactionError>; +} + +/// Validates every registered source and sums what they hold back, chaining the running +/// space total so each source sees what the previous ones reserved. +/// +/// `payout_tx_space` is the space already reserved for the proposer payout tx. +pub fn reserve_end_of_block( + builder_transactions: &[Arc], + ctx: &BlockBuildingContext, + state: &mut BlockState, + payout_tx_space: BlockSpace, +) -> Result { + builder_transactions.iter().try_fold( + EndOfBlockReservation { + space: payout_tx_space, + value: U256::ZERO, + }, + |reserved, builder_txs| { + builder_txs.validate(ctx, state)?; + builder_txs + .reserve_block_space(ctx, state, reserved.space) + .map(|space| EndOfBlockReservation { + space: reserved.space + space, + value: reserved.value + builder_txs.reserve_coinbase_value(ctx), + }) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::building::{ + create_payout_tx, payout_tx::tests::setup, FinalizeRevertStateCurrentIteration, + InsertPayoutTxErr, PartialBlock, ThreadBlockBuildingContext, + }; + use crate::utils::constants::BASE_TX_GAS; + use alloy_primitives::U256; + + /// Test source emitting `count` zero value self transfers signed by the builder. + #[derive(Debug)] + struct SelfTransfers { + count: u64, + } + + impl BuilderTransactions for SelfTransfers { + fn name(&self) -> &str { + "self_transfers" + } + + fn reserve_block_space( + &self, + _ctx: &BlockBuildingContext, + _state: &mut BlockState, + _space_reserved_so_far: BlockSpace, + ) -> Result { + Ok(BlockSpace::new(BASE_TX_GAS * self.count, 0, 0)) + } + + fn transactions( + &self, + ctx: &BlockBuildingContext, + nonce: u64, + ) -> Result, BuilderTransactionError> { + (0..self.count) + .map(|index| { + let tx = create_payout_tx( + ctx.chain_spec.as_ref(), + ctx.evm_env.block_env.basefee, + &ctx.builder_signer, + nonce + index, + ctx.builder_signer.address, + BASE_TX_GAS, + U256::ZERO, + )?; + TransactionSignedEcRecoveredWithBlobs::new_no_blobs(tx) + .map_err(BuilderTransactionError::msg) + }) + .collect() + } + } + + /// Test source that reports how much space was already reserved when it was asked. + #[derive(Debug)] + struct SpaceReporter { + space: BlockSpace, + value: U256, + seen_reserved: std::sync::Mutex>, + } + + impl SpaceReporter { + fn new(gas: u64, value: u64) -> Self { + Self { + space: BlockSpace::new(gas, 0, 0), + value: U256::from(value), + seen_reserved: std::sync::Mutex::new(Vec::new()), + } + } + } + + impl BuilderTransactions for SpaceReporter { + fn name(&self) -> &str { + "space_reporter" + } + + fn reserve_coinbase_value(&self, _ctx: &BlockBuildingContext) -> U256 { + self.value + } + + fn reserve_block_space( + &self, + _ctx: &BlockBuildingContext, + _state: &mut BlockState, + space_reserved_so_far: BlockSpace, + ) -> Result { + self.seen_reserved + .lock() + .expect("seen_reserved poisoned") + .push(space_reserved_so_far); + Ok(self.space) + } + + fn transactions( + &self, + _ctx: &BlockBuildingContext, + _nonce: u64, + ) -> Result, BuilderTransactionError> { + Ok(Vec::new()) + } + } + + /// Test source emitting a tx that cannot fit in the block. + #[derive(Debug)] + struct DoesNotFit; + + impl BuilderTransactions for DoesNotFit { + fn name(&self) -> &str { + "does_not_fit" + } + + fn reserve_block_space( + &self, + _ctx: &BlockBuildingContext, + _state: &mut BlockState, + _space_reserved_so_far: BlockSpace, + ) -> Result { + Ok(BlockSpace::new(BASE_TX_GAS, 0, 0)) + } + + fn transactions( + &self, + ctx: &BlockBuildingContext, + nonce: u64, + ) -> Result, BuilderTransactionError> { + // Way over the 30M block gas limit of the test context. + create_payout_tx( + ctx.chain_spec.as_ref(), + ctx.evm_env.block_env.basefee, + &ctx.builder_signer, + nonce, + ctx.builder_signer.address, + 40_000_000, + U256::ZERO, + ) + .map_err(BuilderTransactionError::from) + .and_then(|tx| { + TransactionSignedEcRecoveredWithBlobs::new_no_blobs(tx) + .map_err(BuilderTransactionError::msg) + }) + .map(|tx| vec![tx]) + } + } + + /// Test source failing validation. + #[derive(Debug)] + struct FailsValidation; + + impl BuilderTransactions for FailsValidation { + fn name(&self) -> &str { + "fails_validation" + } + + fn validate( + &self, + _ctx: &BlockBuildingContext, + _state: &mut BlockState, + ) -> Result<(), BuilderTransactionError> { + Err(BuilderTransactionError::msg("nope")) + } + + fn reserve_block_space( + &self, + _ctx: &BlockBuildingContext, + _state: &mut BlockState, + _space_reserved_so_far: BlockSpace, + ) -> Result { + panic!("reserve_block_space must not be called when validation fails") + } + + fn transactions( + &self, + _ctx: &BlockBuildingContext, + _nonce: u64, + ) -> Result, BuilderTransactionError> { + unreachable!("transactions must not be called when validation fails") + } + } + + /// Inserts the end of block txs on a fresh PartialBlock and returns it. + fn insert_end_of_block_txs( + ctx: &BlockBuildingContext, + state: &mut BlockState, + payout_value: U256, + ) -> ( + PartialBlock<(), crate::building::NullPartialBlockExecutionTracer>, + FinalizeRevertStateCurrentIteration, + ) { + let mut partial_block = PartialBlock::new(false); + let mut revert_state = FinalizeRevertStateCurrentIteration::default(); + partial_block + .insert_refunds_and_proposer_payout_tx( + BASE_TX_GAS, + payout_value, + ctx, + &mut ThreadBlockBuildingContext::default(), + state, + false, + &mut revert_state, + ) + .expect("end of block txs must be inserted"); + (partial_block, revert_state) + } + + #[test] + fn no_builder_transactions_inserts_only_the_payout_tx() { + let (proposer, ctx, mut state) = setup(None, false); + + let (partial_block, _) = insert_end_of_block_txs(&ctx, &mut state, U256::from(1u64)); + + assert_eq!(partial_block.executed_tx_infos.len(), 1); + let payout = &partial_block.executed_tx_infos[0]; + assert_eq!(payout.tx.to(), Some(proposer)); + assert_eq!(payout.tx.nonce(), 0); + } + + #[test] + fn builder_txs_are_inserted_before_the_payout_tx() { + let (proposer, ctx, mut state) = setup(None, false); + let ctx = ctx.with_builder_transactions(vec![Arc::new(SelfTransfers { count: 2 })]); + let builder = ctx.builder_signer.address; + + let (partial_block, _) = insert_end_of_block_txs(&ctx, &mut state, U256::from(1u64)); + + let txs = &partial_block.executed_tx_infos; + assert_eq!(txs.len(), 3); + // Builder txs first, with consecutive nonces starting at the builder nonce. + assert_eq!(txs[0].tx.to(), Some(builder)); + assert_eq!(txs[0].tx.nonce(), 0); + assert_eq!(txs[1].tx.to(), Some(builder)); + assert_eq!(txs[1].tx.nonce(), 1); + // The payout tx stays last, which the rest of finalization relies on. + assert_eq!(txs[2].tx.to(), Some(proposer)); + assert_eq!(txs[2].tx.nonce(), 2); + assert!(txs.iter().all(|tx| tx.receipt.success)); + } + + #[test] + fn builder_txs_survive_a_reseal_untouched() { + let (proposer, ctx, mut state) = setup(None, false); + let ctx = ctx.with_builder_transactions(vec![Arc::new(SelfTransfers { count: 2 })]); + let builder = ctx.builder_signer.address; + + let (mut partial_block, revert_state) = + insert_end_of_block_txs(&ctx, &mut state, U256::from(1u64)); + + // Reseal with a different bid: only the payout tx is reverted and rebuilt. + partial_block.adjust_finalize_block_revert_to_prefinalized_state(revert_state, &mut state); + assert_eq!(partial_block.executed_tx_infos.len(), 2); + + partial_block + .insert_refunds_and_proposer_payout_tx( + BASE_TX_GAS, + U256::from(2u64), + &ctx, + &mut ThreadBlockBuildingContext::default(), + &mut state, + true, + &mut FinalizeRevertStateCurrentIteration::default(), + ) + .expect("resealed block must be inserted"); + + let txs = &partial_block.executed_tx_infos; + assert_eq!(txs.len(), 3); + assert_eq!(txs[0].tx.to(), Some(builder)); + assert_eq!(txs[1].tx.to(), Some(builder)); + assert_eq!(txs[2].tx.to(), Some(proposer)); + // The rebuilt payout tx keeps the nonce the builder txs left it at. + assert_eq!(txs[2].tx.nonce(), 2); + } + + #[test] + fn a_builder_tx_that_cannot_be_committed_fails_the_block() { + let (_, ctx, mut state) = setup(None, false); + let ctx = ctx.with_builder_transactions(vec![Arc::new(DoesNotFit)]); + + let mut partial_block = PartialBlock::new(false); + let result = partial_block.insert_refunds_and_proposer_payout_tx( + BASE_TX_GAS, + U256::ZERO, + &ctx, + &mut ThreadBlockBuildingContext::default(), + &mut state, + false, + &mut FinalizeRevertStateCurrentIteration::default(), + ); + + assert!(matches!(result, Err(InsertPayoutTxErr::TxErr(_)))); + } + + #[test] + fn reserved_space_and_value_are_summed_and_chained() { + let (_, ctx, mut state) = setup(None, false); + let first = Arc::new(SpaceReporter::new(1_000, 7)); + let second = Arc::new(SpaceReporter::new(2_000, 11)); + let sources: Vec> = vec![first.clone(), second.clone()]; + + let payout_space = BlockSpace::new(BASE_TX_GAS, 100, 0); + let reserved = reserve_end_of_block(&sources, &ctx, &mut state, payout_space) + .expect("reservation must succeed"); + + assert_eq!(reserved.space, BlockSpace::new(BASE_TX_GAS + 3_000, 100, 0)); + // Value comes off the bid ceiling, so it must be summed too. + assert_eq!(reserved.value, U256::from(18u64)); + // Each source sees everything reserved before it. + assert_eq!( + *first.seen_reserved.lock().expect("poisoned"), + vec![payout_space] + ); + assert_eq!( + *second.seen_reserved.lock().expect("poisoned"), + vec![BlockSpace::new(BASE_TX_GAS + 1_000, 100, 0)] + ); + } + + #[test] + fn failed_validation_aborts_the_reservation() { + let (_, ctx, mut state) = setup(None, false); + let sources: Vec> = vec![Arc::new(FailsValidation)]; + + let result = reserve_end_of_block(&sources, &ctx, &mut state, BlockSpace::ZERO); + + assert!(matches!(result, Err(BuilderTransactionError::Other(_)))); + } +} diff --git a/crates/rbuilder/src/building/builders/block_building_helper.rs b/crates/rbuilder/src/building/builders/block_building_helper.rs index bb66b4a17..80bf6fda4 100644 --- a/crates/rbuilder/src/building/builders/block_building_helper.rs +++ b/crates/rbuilder/src/building/builders/block_building_helper.rs @@ -9,13 +9,17 @@ use tracing::{debug, trace, warn}; use crate::{ building::{ - builders::BuiltBlockId, cached_reads::CachedDB, estimate_payout_gas_limit, - journal::JournalSequenceNumber, tracers::GasUsedSimulationTracer, BlockBuildingContext, - BlockSpace, BlockState, BuiltBlockTrace, BuiltBlockTraceError, CriticalCommitOrderError, - EstimatePayoutGasErr, ExecutionError, ExecutionResult, FinalizeAdjustmentState, - FinalizeError, FinalizeResult, FinalizeRevertStateCurrentIteration, - NullPartialBlockExecutionTracer, PartialBlock, PartialBlockExecutionTracer, - ThreadBlockBuildingContext, + builder_tx::{reserve_end_of_block, BuilderTransactionError}, + builders::BuiltBlockId, + cached_reads::CachedDB, + estimate_payout_gas_limit, + journal::JournalSequenceNumber, + tracers::GasUsedSimulationTracer, + BlockBuildingContext, BlockSpace, BlockState, BuiltBlockTrace, BuiltBlockTraceError, + CriticalCommitOrderError, EstimatePayoutGasErr, ExecutionError, ExecutionResult, + FinalizeAdjustmentState, FinalizeError, FinalizeResult, + FinalizeRevertStateCurrentIteration, NullPartialBlockExecutionTracer, PartialBlock, + PartialBlockExecutionTracer, ThreadBlockBuildingContext, }, live_builder::block_output::bidding_service_interface::CompetitionBidContext, provider::StateProviderSource, @@ -157,6 +161,13 @@ pub struct BlockBuildingHelperFromProvider< partial_block: PartialBlock, /// Gas reserved for the final payout txs from coinbase to fee recipient. payout_tx_gas: u64, + /// Gas reserved for everything the builder appends at the end of the block: the + /// payout tx plus the registered [`crate::building::builder_tx::BuilderTransactions`]. + /// Equal to payout_tx_gas when no builder txs are registered. + end_of_block_gas: u64, + /// Value the registered builder txs will move out of the builder account, which is + /// also what pays the proposer. Zero when no builder txs are registered. + end_of_block_value: U256, /// Name of the builder that pregenerated this block. /// Might be ambiguous if several building parts were involved... builder_name: String, @@ -178,6 +189,8 @@ pub enum BlockBuildingHelperError { ProviderError(#[from] reth_errors::ProviderError), #[error("Unable estimate payout gas: {0}")] UnableToEstimatePayoutGas(#[from] EstimatePayoutGasErr), + #[error("Builder tx error: {0}")] + BuilderTransaction(#[from] BuilderTransactionError), #[error("pre_block_call failed")] PreBlockCallFailed, #[error("InsertPayoutTxErr while finishing block: {0}")] @@ -282,8 +295,19 @@ impl< &mut block_state, BlockSpace::ZERO, )?; - partial_block.reserve_block_space(payout_tx_space); let payout_tx_gas = payout_tx_space.gas; + // Everything the builder itself puts at the end of the block: the payout tx plus + // whatever the registered BuilderTransactions ask for. Reserved as one block so + // filling the block with orders can never eat into it. + let end_of_block = reserve_end_of_block( + &building_ctx.builder_transactions, + &building_ctx, + &mut block_state, + payout_tx_space, + )?; + partial_block.reserve_block_space(end_of_block.space); + let end_of_block_gas = end_of_block.space.gas; + let end_of_block_value = end_of_block.value; let mut built_block_trace = BuiltBlockTrace::new(built_block_id, next_journal_sequence_number); @@ -294,6 +318,8 @@ impl< block_state, partial_block, payout_tx_gas, + end_of_block_gas, + end_of_block_value, builder_name, building_ctx, built_block_trace, @@ -597,7 +623,9 @@ impl< fn true_block_value(&self) -> Result { Ok(self .partial_block - .get_proposer_payout_tx_value(self.payout_tx_gas, &self.building_ctx)?) + .get_proposer_payout_tx_value(self.end_of_block_gas, &self.building_ctx)? + .checked_sub(self.end_of_block_value) + .ok_or(crate::building::InsertPayoutTxErr::ProfitTooLow)?) } fn finalize_block( @@ -638,6 +666,8 @@ impl< block_state, partial_block: self.partial_block.clone(), payout_tx_gas: self.payout_tx_gas, + end_of_block_gas: self.end_of_block_gas, + end_of_block_value: self.end_of_block_value, builder_name: self.builder_name.clone(), building_ctx: self.building_ctx.clone(), built_block_trace: self.built_block_trace.clone(), diff --git a/crates/rbuilder/src/building/mod.rs b/crates/rbuilder/src/building/mod.rs index 9f8e09d8a..c7ba8d38e 100644 --- a/crates/rbuilder/src/building/mod.rs +++ b/crates/rbuilder/src/building/mod.rs @@ -71,6 +71,7 @@ use tx_sim_cache::TxExecutionCache; pub mod bid_adjustments; pub mod block_orders; +pub mod builder_tx; pub mod builders; pub mod built_block_trace; pub mod cached_reads; @@ -88,8 +89,9 @@ pub mod tracers; pub mod tx_sim_cache; pub use self::{ - block_orders::*, builders::mock_block_building_helper::MockRootHasher, built_block_trace::*, - order_commit::*, payout_tx::*, sim::simulate_order, tracers::SimulationTracer, + block_orders::*, builder_tx::BuilderTransactions, + builders::mock_block_building_helper::MockRootHasher, built_block_trace::*, order_commit::*, + payout_tx::*, sim::simulate_order, tracers::SimulationTracer, }; #[cfg(test)] @@ -158,6 +160,10 @@ pub struct BlockBuildingContext { pub mempool_tx_detector: Arc, pub faster_finalize: bool, pub mev_blocker_price: U256, + /// Sources of the txs the builder appends at the end of the block, after the orders + /// and the accumulated refunds and before the proposer payout tx. + /// See [`crate::building::builder_tx::BuilderTransactions`]. + pub builder_transactions: Vec>, pub adjustment_fee_payers: ahash::HashSet
, /// Cached from evm_env.block_env.number but as BlockNumber. Avoid conversions all over the code. block_number: BlockNumber, @@ -260,6 +266,7 @@ impl BlockBuildingContext { mempool_tx_detector, faster_finalize, mev_blocker_price, + builder_transactions: Vec::new(), adjustment_fee_payers, block_number, }) @@ -345,11 +352,22 @@ impl BlockBuildingContext { mempool_tx_detector: Arc::new(MempoolTxsDetector::new()), faster_finalize: true, mev_blocker_price, + builder_transactions: Vec::new(), adjustment_fee_payers: Default::default(), block_number, } } + /// Registers the sources of the txs the builder appends at the end of the block. + /// See [`crate::building::builder_tx::BuilderTransactions`]. + pub fn with_builder_transactions( + mut self, + builder_transactions: Vec>, + ) -> Self { + self.builder_transactions = builder_transactions; + self + } + pub fn max_blob_gas_per_block(&self) -> u64 { self.max_blob_gas_per_block } @@ -599,6 +617,10 @@ pub enum InsertPayoutTxErr { CombinedRefundTxReverted, #[error("Payout tx reverted")] PayoutTxReverted, + #[error("Builder tx from {0} reverted")] + BuilderTxReverted(String), + #[error("Builder tx error: {0}")] + BuilderTxErr(#[from] crate::building::builder_tx::BuilderTransactionError), #[error("Signer error: {0}")] SignerError(#[from] secp256k1::Error), #[error("Tx error: {0}")] @@ -900,6 +922,37 @@ impl Result { + builder_txs.transactions(ctx, nonce)?.into_iter().try_fold( + nonce, + |nonce, builder_tx| { + let committed = fork.commit_tx(&builder_tx, self.space_state)??; + committed + .tx_info + .receipt + .success + .then_some(()) + .ok_or_else(|| { + InsertPayoutTxErr::BuilderTxReverted( + builder_txs.name().to_string(), + ) + }) + .map(|()| { + self.space_state.use_space(committed.space_used()); + self.executed_tx_infos.push(committed.tx_info); + nonce + 1 + }) + }, + ) + }, + )?; } let tx = create_payout_tx( diff --git a/crates/rbuilder/src/building/payout_tx.rs b/crates/rbuilder/src/building/payout_tx.rs index 965a5dee3..642efbf59 100644 --- a/crates/rbuilder/src/building/payout_tx.rs +++ b/crates/rbuilder/src/building/payout_tx.rs @@ -214,7 +214,7 @@ where } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::building::{ builders::mock_block_building_helper::MockRootHasher, @@ -230,8 +230,12 @@ mod tests { use revm::primitives::hardfork::SpecId; use std::sync::Arc; - fn setup( + /// Test context whose fee recipient is `proposer`. When `proposer_has_code` the + /// proposer is a contract, which is what forces the slow payout gas estimation path. + /// The builder signer is always funded so it can pay for the txs it signs. + pub(crate) fn setup( tx_gas_limit_cap: Option, + proposer_has_code: bool, ) -> (Address, BlockBuildingContext, BlockState) { let signer = Signer::random(); let proposer = Address::random(); @@ -252,7 +256,19 @@ mod tests { Account { balance: U256::ZERO, nonce: 1, - bytecode_hash: Some(B256::random()), + bytecode_hash: proposer_has_code.then(B256::random), + }, + ) + .unwrap(); + // Fund the builder so it can pay for the txs it signs itself. + provider_rw + .tx_ref() + .put::( + signer.address, + Account { + balance: U256::from(10u64).pow(U256::from(18u64)), + nonce: 0, + bytecode_hash: None, }, ) .unwrap(); @@ -290,7 +306,7 @@ mod tests { #[test] fn estimate_payout_tx_gas_limit() { // Pre Fusaka block: no per-tx cap. - let (proposer, ctx, mut state) = setup(None); + let (proposer, ctx, mut state) = setup(None, true); let empty_block = estimate_payout_gas_limit(proposer, &ctx, &mut state, BlockSpace::ZERO); assert_matches!(empty_block, Ok(_)); @@ -306,7 +322,7 @@ mod tests { assert_matches!(full_block, Err(_)); // Post Fusaka block: EIP-7825 caps one tx at 16,777,216 gas - let (proposer, ctx, mut state) = setup(Some(16_777_216)); + let (proposer, ctx, mut state) = setup(Some(16_777_216), true); let empty_block = estimate_payout_gas_limit(proposer, &ctx, &mut state, BlockSpace::ZERO); assert_matches!(empty_block, Ok(_));