Skip to content

Commit 79910da

Browse files
MavenRainclaude
andcommitted
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 <noreply@anthropic.com> Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
1 parent 4bb5f2e commit 79910da

2 files changed

Lines changed: 86 additions & 1 deletion

File tree

crates/rbuilder/src/building/mod.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ use std::{
7070
};
7171
use thiserror::Error;
7272
use time::OffsetDateTime;
73-
use tracing::{error, trace};
73+
use tracing::{error, trace, warn};
7474
use tx_sim_cache::TxExecutionCache;
7575

7676
pub mod bid_adjustments;
@@ -864,6 +864,31 @@ impl<Tracer: SimulationTracer, PartialBlockExecutionTracerType: PartialBlockExec
864864
}
865865
}
866866

867+
// flashbots/rbuilder#296: the proposer payout is sent from the builder
868+
// coinbase, so the coinbase must hold at least the transferred value
869+
// plus the gas the EVM locks up front (gas_limit * basefee, as the
870+
// payout tx sets max_priority_fee_per_gas = 0 and max_fee_per_gas =
871+
// basefee). If it does not, the payout tx is not sendable (in the
872+
// limiting value-less case the floor is BASE_TX_GAS * basefee). Warn so
873+
// the condition is visible up front rather than only as a downstream
874+
// payout-tx revert. Checked after the refunds above, which also spend
875+
// from the coinbase.
876+
let builder_balance = fork
877+
.state
878+
.balance(builder_signer.address)
879+
.map_err(CriticalCommitOrderError::Reth)?;
880+
let payout_tx_cost =
881+
proposer_payout_tx_cost(value, gas_limit, ctx.evm_env.block_env.basefee);
882+
if builder_balance < payout_tx_cost {
883+
warn!(
884+
builder = %builder_signer.address,
885+
%builder_balance,
886+
%payout_tx_cost,
887+
payout_value = %value,
888+
"Builder coinbase balance is below the cost of the proposer payout tx; it will not be sendable"
889+
);
890+
}
891+
867892
let tx = create_payout_tx(
868893
ctx.chain_spec.as_ref(),
869894
ctx.evm_env.block_env.basefee,

crates/rbuilder/src/building/payout_tx.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ pub fn create_payout_tx(
3939
signer.sign_tx(tx)
4040
}
4141

42+
/// Minimum builder (coinbase) balance required to send the proposer payout
43+
/// transaction: the transferred `value` plus the gas the EVM locks up front
44+
/// (`gas_limit * basefee`, since the payout tx sets `max_fee_per_gas = basefee`
45+
/// and `max_priority_fee_per_gas = 0`). A builder holding less than this cannot
46+
/// cover the payout tx and it will not be sendable; in the limiting case of a
47+
/// value-less payout the floor is `BASE_TX_GAS * basefee`. See
48+
/// flashbots/rbuilder#296. Saturating so an implausible input cannot panic.
49+
pub fn proposer_payout_tx_cost(value: U256, gas_limit: u64, basefee: u64) -> U256 {
50+
value.saturating_add(U256::from(gas_limit).saturating_mul(U256::from(basefee)))
51+
}
52+
4253
#[derive(Debug, thiserror::Error)]
4354
pub enum PayoutTxErr {
4455
#[error("Reth error: {0}")]
@@ -287,4 +298,53 @@ mod tests {
287298
assert_matches!(estimate_result, Ok(_));
288299
assert_eq!(estimate_result.unwrap().gas, 21_000);
289300
}
301+
302+
#[test]
303+
fn payout_tx_cost_covers_value_and_gas() {
304+
let basefee = INITIAL_BASE_FEE;
305+
306+
// A value-less payout still needs BASE_TX_GAS * basefee: the "21k gas"
307+
// floor from flashbots/rbuilder#296.
308+
assert_eq!(
309+
proposer_payout_tx_cost(U256::ZERO, BASE_TX_GAS, basefee),
310+
U256::from(BASE_TX_GAS) * U256::from(basefee),
311+
);
312+
313+
// The transferred value is added on top of the gas cost.
314+
let value = U256::from(7_000_000_000u64);
315+
assert_eq!(
316+
proposer_payout_tx_cost(value, BASE_TX_GAS, basefee),
317+
value + U256::from(BASE_TX_GAS) * U256::from(basefee),
318+
);
319+
}
320+
321+
#[test]
322+
fn payout_tx_cost_matches_guard_boundary() {
323+
let basefee = INITIAL_BASE_FEE;
324+
let value = U256::from(7_000_000_000u64);
325+
// Independently computed cost (NOT via the function under test) so the
326+
// assertions below depend on proposer_payout_tx_cost's real output.
327+
let exact = value + U256::from(BASE_TX_GAS) * U256::from(basefee);
328+
329+
// The guard in insert_refunds_and_proposer_payout_tx warns iff
330+
// builder_balance < proposer_payout_tx_cost(...). A balance of exactly
331+
// the cost is sufficient (no warn); one wei less is insufficient.
332+
assert!(
333+
exact >= proposer_payout_tx_cost(value, BASE_TX_GAS, basefee),
334+
"balance equal to the cost is sufficient",
335+
);
336+
assert!(
337+
exact - U256::from(1) < proposer_payout_tx_cost(value, BASE_TX_GAS, basefee),
338+
"balance one wei below the cost is insufficient",
339+
);
340+
}
341+
342+
#[test]
343+
fn payout_tx_cost_saturates() {
344+
// Implausible inputs must saturate rather than panic on overflow.
345+
assert_eq!(
346+
proposer_payout_tx_cost(U256::MAX, u64::MAX, u64::MAX),
347+
U256::MAX,
348+
);
349+
}
290350
}

0 commit comments

Comments
 (0)