From 79910daf158e383e0515ec1a80d0dee4b9e3c52d Mon Sep 17 00:00:00 2001 From: Onyeka Obi Date: Mon, 6 Jul 2026 07:45:12 -0700 Subject: [PATCH] feat: warn when the coinbase cannot cover the proposer payout tx In L1 block building the proposer payout is an EIP-1559 transaction sent from the builder coinbase (max_fee_per_gas = basefee, max_priority_fee_per_gas = 0), so the EVM locks value + gas_limit * basefee up front. If the coinbase holds less than that, the payout tx is not sendable and block construction is silently corrupted, surfacing only as a downstream payout-tx revert. In the limiting value-less case the floor is BASE_TX_GAS * basefee (the "21k gas" case in the issue). Add proposer_payout_tx_cost(value, gas_limit, basefee) and, in insert_refunds_and_proposer_payout_tx, warn when the coinbase balance (read after the refunds, which also spend from the coinbase) is below that cost. Warn-only: no early return, the existing PayoutTxReverted path is unchanged. Closes #296 Co-Authored-By: Claude Opus 4.8 Signed-off-by: Onyeka Obi --- crates/rbuilder/src/building/mod.rs | 27 +++++++++- crates/rbuilder/src/building/payout_tx.rs | 60 +++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/crates/rbuilder/src/building/mod.rs b/crates/rbuilder/src/building/mod.rs index 84d2c59a7..297aab60e 100644 --- a/crates/rbuilder/src/building/mod.rs +++ b/crates/rbuilder/src/building/mod.rs @@ -70,7 +70,7 @@ use std::{ }; use thiserror::Error; use time::OffsetDateTime; -use tracing::{error, trace}; +use tracing::{error, trace, warn}; use tx_sim_cache::TxExecutionCache; pub mod bid_adjustments; @@ -864,6 +864,31 @@ impl U256 { + value.saturating_add(U256::from(gas_limit).saturating_mul(U256::from(basefee))) +} + #[derive(Debug, thiserror::Error)] pub enum PayoutTxErr { #[error("Reth error: {0}")] @@ -287,4 +298,53 @@ mod tests { assert_matches!(estimate_result, Ok(_)); assert_eq!(estimate_result.unwrap().gas, 21_000); } + + #[test] + fn payout_tx_cost_covers_value_and_gas() { + let basefee = INITIAL_BASE_FEE; + + // A value-less payout still needs BASE_TX_GAS * basefee: the "21k gas" + // floor from flashbots/rbuilder#296. + assert_eq!( + proposer_payout_tx_cost(U256::ZERO, BASE_TX_GAS, basefee), + U256::from(BASE_TX_GAS) * U256::from(basefee), + ); + + // The transferred value is added on top of the gas cost. + let value = U256::from(7_000_000_000u64); + assert_eq!( + proposer_payout_tx_cost(value, BASE_TX_GAS, basefee), + value + U256::from(BASE_TX_GAS) * U256::from(basefee), + ); + } + + #[test] + fn payout_tx_cost_matches_guard_boundary() { + let basefee = INITIAL_BASE_FEE; + let value = U256::from(7_000_000_000u64); + // Independently computed cost (NOT via the function under test) so the + // assertions below depend on proposer_payout_tx_cost's real output. + let exact = value + U256::from(BASE_TX_GAS) * U256::from(basefee); + + // The guard in insert_refunds_and_proposer_payout_tx warns iff + // builder_balance < proposer_payout_tx_cost(...). A balance of exactly + // the cost is sufficient (no warn); one wei less is insufficient. + assert!( + exact >= proposer_payout_tx_cost(value, BASE_TX_GAS, basefee), + "balance equal to the cost is sufficient", + ); + assert!( + exact - U256::from(1) < proposer_payout_tx_cost(value, BASE_TX_GAS, basefee), + "balance one wei below the cost is insufficient", + ); + } + + #[test] + fn payout_tx_cost_saturates() { + // Implausible inputs must saturate rather than panic on overflow. + assert_eq!( + proposer_payout_tx_cost(U256::MAX, u64::MAX, u64::MAX), + U256::MAX, + ); + } }