Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion crates/rbuilder/src/building/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ pub struct BlockBuildingContext {
/// None: coinbase = attributes.suggested_fee_recipient. No payoffs allowed.
/// Some(signer): coinbase = signer.
pub builder_signer: Option<Signer>,
/// The real coinbase signer used for sponsorship transactions
/// This is always the actual coinbase signer, never randomized
pub coinbase_signer: Option<Signer>,
pub blocklist: BlockList,
pub extra_data: Vec<u8>,
/// Excess blob gas calculated from the parent block header
Expand Down Expand Up @@ -196,6 +199,7 @@ impl BlockBuildingContext {
attributes,
chain_spec,
builder_signer: Some(signer),
coinbase_signer: Some(signer),
blocklist,
extra_data,
excess_blob_gas,
Expand Down Expand Up @@ -294,6 +298,7 @@ impl BlockBuildingContext {
attributes,
chain_spec,
builder_signer,
coinbase_signer: builder_signer,
blocklist,
extra_data: Vec::new(),
excess_blob_gas: onchain_block.header.excess_blob_gas,
Expand Down Expand Up @@ -322,7 +327,7 @@ impl BlockBuildingContext {
Default::default(),
Default::default(),
Default::default(),
Default::default(),
Some(Signer::random()), // Provide a signer for sponsorship
Arc::new(MockRootHasher {}),
false,
)
Expand Down
219 changes: 219 additions & 0 deletions crates/rbuilder/src/building/order_commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ use revm::{
};
use std::{collections::HashMap, sync::Arc};
use thiserror::Error;
use tracing::trace;

/// Maximum amount that can be sponsored in a single transaction (2 ETH)
const MAX_SPONSOR_AMOUNT: U256 = U256::from_limbs([2_000_000_000_000_000_000, 0, 0, 0]); // 2 * 10^18 wei = 2 ETH

Copilot AI Aug 6, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic number should be replaced with a more maintainable constant. Consider using U256::from(2) * U256::from(10).pow(U256::from(18)) or a similar expression that makes the calculation clearer.

Suggested change
const MAX_SPONSOR_AMOUNT: U256 = U256::from_limbs([2_000_000_000_000_000_000, 0, 0, 0]); // 2 * 10^18 wei = 2 ETH
const MAX_SPONSOR_AMOUNT: U256 = U256::from(2) * U256::from(10).pow(U256::from(18)); // 2 * 10^18 wei = 2 ETH

Copilot uses AI. Check for mistakes.

/// Safety buffer for sponsorship transactions to account for gas price changes,
/// timing differences, and EVM precision issues (0.001 ETH)
const SPONSORSHIP_SAFETY_BUFFER: U256 = U256::from_limbs([1_000_000_000_000_000, 0, 0, 0]); // 0.001 ETH

Copilot AI Aug 6, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic number should be replaced with a more maintainable constant. Consider using U256::from(10).pow(U256::from(15)) or a similar expression that makes the calculation clearer.

Suggested change
const SPONSORSHIP_SAFETY_BUFFER: U256 = U256::from_limbs([1_000_000_000_000_000, 0, 0, 0]); // 0.001 ETH
const SPONSORSHIP_SAFETY_BUFFER: U256 = U256::from(10).pow(U256::from(15)); // 0.001 ETH

Copilot uses AI. Check for mistakes.

#[derive(Clone)]
pub struct BlockState {
Expand Down Expand Up @@ -322,6 +330,7 @@ pub struct PartialBlockFork<'a, 'b, 'c, 'd, Tracer: SimulationTracer> {
tmp_used_state_tracer: UsedStateTrace,
}

#[derive(Clone)]
pub struct PartialBlockRollobackPoint {
rollobacks: usize,
}
Expand Down Expand Up @@ -740,6 +749,7 @@ impl<'a, 'b, 'c, 'd, Tracer: SimulationTracer> PartialBlockFork<'a, 'b, 'c, 'd,
combined_refunds: &HashMap<Address, U256>,
) -> Result<Result<BundleOk, BundleErr>, CriticalCommitOrderError> {
let mut refundable_profit = U256::ZERO;
let coinbase_balance_before = self.coinbase_balance()?;
let mut insert = BundleOk {
gas_used: 0,
cumulative_gas_used,
Expand All @@ -754,6 +764,215 @@ impl<'a, 'b, 'c, 'd, Tracer: SimulationTracer> PartialBlockFork<'a, 'b, 'c, 'd,
for tx_with_blobs in &bundle.txs {
let tx_hash = tx_with_blobs.hash();
let rollback_point = self.rollback_point();

// ------- fast sponsorship pre-check ---------------------------------
// Before simulating the tx we check if the sender lacks funds and, if
// possible, inject a sponsor transfer up-front. This avoids one extra
// EVM execution per under-funded tx.
{
let tx_inner = tx_with_blobs.internal_tx_unsecure();
let sender = tx_inner.signer();

// Read sender balance once.
let sender_balance = self.state.balance(
sender,
&self.ctx.shared_cached_reads,
&mut self.local_ctx.cached_reads,
)?;

let effective_gas_price = tx_inner.max_fee_per_gas();

Copilot AI Aug 6, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using max_fee_per_gas() for fund calculation may be incorrect. For EIP-1559 transactions, the actual gas price depends on the base fee and priority fee. Consider using the effective gas price calculation that accounts for min(max_fee_per_gas, base_fee + max_priority_fee_per_gas).

Suggested change
let effective_gas_price = tx_inner.max_fee_per_gas();
// Calculate effective gas price according to EIP-1559 rules
let effective_gas_price = match tx_inner.tx_type() {
// EIP-1559 transaction (type 2)
Some(2) => {
let max_fee_per_gas = tx_inner.max_fee_per_gas();
let max_priority_fee_per_gas = tx_inner.max_priority_fee_per_gas();
let base_fee = self.ctx.block_env.basefee;
let base_fee_plus_priority = base_fee.saturating_add(max_priority_fee_per_gas);
std::cmp::min(max_fee_per_gas, base_fee_plus_priority)
}
// Legacy or EIP-2930 transaction
_ => tx_inner.gas_price(),
};

Copilot uses AI. Check for mistakes.
let required_funds = U256::from(tx_inner.gas_limit())
* U256::from(effective_gas_price)
+ tx_inner.value();

let shortfall = required_funds.saturating_sub(sender_balance);

// Add a fixed safety buffer to account for gas price variations and timing issues
let safety_buffer = SPONSORSHIP_SAFETY_BUFFER;
let total_sponsor_amount = shortfall + safety_buffer;

if shortfall > U256::ZERO {
// Check if shortfall exceeds the maximum sponsor amount
if total_sponsor_amount > MAX_SPONSOR_AMOUNT {
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I read it correctly all these exits from the sponsorship branch like continue here are related to loop over txs in the bundle which means that this bundle tx will simply be skipped? If so then its not correct.

@quasarwin quasarwin Aug 25, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No thats not correct. We are prchecking if a tx needs sponsorship prior to simulation. So if it needs sponsorship, and qualifies for sponsorship (ie below our MAX_SPONSOR_AMOUNT and assuming builder balance can support it), it will then attempt to add a sponsorship tx prior to the transaction that would otherwise fail. When it hits continue here, we just skip trying to add a sponsorship tx, and importantly roll back the state. So we're just skipping the shortfall loop, and that loop only attempts to add a sponsorship tx, it doesn't affect the tx that requires the sponsorship.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See next comment, yes this is a bug introduced from extracting code from our fork to this one.

}

if let Some(builder_signer) = &self.ctx.builder_signer {
// For sponsorship transactions, use the coinbase_signer
// This ensures sponsorship works identically in simulation and real building
let sponsor_signer =
self.ctx.coinbase_signer.as_ref().unwrap_or(builder_signer);

let builder_balance = self.state.balance(
sponsor_signer.address,
&self.ctx.shared_cached_reads,
&mut self.local_ctx.cached_reads,
)?;

if builder_balance < total_sponsor_amount {
// Skip sponsorship if builder balance is insufficient
continue;
}

// Get the correct nonce accounting for previous sponsor transactions in this bundle
let builder_nonce = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this branch is not needed, its enough to just use else version of this branch

@quasarwin quasarwin Aug 25, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming you mean the if statement on line 800... when coinbase_payment is set to true in the config, the coin bases signing capability will be removed (builder_signer will be None). The coinbase is needed to sign the prepayment transaction for the sponsorship.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized I misread this one.. You're referring to the builder nonce code branch. This is handling the case when we sponsor multiple transactions within a single bundle. So the flow should be:

  1. Builder's onchain nonce is 100
  2. First sponsored tx: uses nonce 100, updates nonces_updated to (builder, 100)
  3. Second sponsored tx: finds (builder, 100) in nonces_updated, uses 101
  4. Third sponsored tx: finds (builder, 101) in nonces_updated, uses 102

if let Some((_, current_nonce)) = insert
.nonces_updated
.iter()
.find(|(addr, _)| *addr == sponsor_signer.address)
{
// current_nonce is the last used nonce, so we need the next one
*current_nonce + 1
} else {
self.state.nonce(
sponsor_signer.address,
&self.ctx.shared_cached_reads,
&mut self.local_ctx.cached_reads,
)?
}
};

// Build sponsorship tx (simple transfer).
let sponsor_tx_result = create_payout_tx(
self.ctx.chain_spec.as_ref(),
self.ctx.evm_env.block_env.basefee,
sponsor_signer,
builder_nonce,
sender,
21_000,

Copilot AI Aug 6, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gas limit value 21_000 is a magic number. Consider defining it as a named constant like SIMPLE_TRANSFER_GAS_LIMIT to make its purpose clear and improve maintainability.

Suggested change
21_000,
SIMPLE_TRANSFER_GAS_LIMIT,

Copilot uses AI. Check for mistakes.
total_sponsor_amount,
);

if let Ok(unsigned_tx) = sponsor_tx_result {
let sponsor_tx =
TransactionSignedEcRecoveredWithBlobs::new_no_blobs(unsigned_tx)
.unwrap();

// Validate nonce to prevent reuse
let current_state_nonce = self.state.nonce(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you check nonce here, code above should give you correct nonce

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just defensive programming to ensure if there are any race conditions or alterations introduced accidentally in the future, we catch them. Our fork has a lot more trace logging I added at various points that would actually log this if it ever happened. If it did, we'd miss the slot and also cause demotions of optimistic relay submissions, so it's a critical error to catch.

sponsor_signer.address,
&self.ctx.shared_cached_reads,
&mut self.local_ctx.cached_reads,
)?;

if builder_nonce < current_state_nonce {
// Skip sponsorship if nonce is too low (already used)
continue;
}

// Execute sponsor tx.
let sponsor_result = self.commit_tx(
&sponsor_tx,
insert.cumulative_gas_used,
gas_reserved,
insert.cumulative_blob_gas_used,
)?;

trace!(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all other logs in the builder use style like this

trace!(?tx_hash, success = ..., "Sponsor tx execution result")

"Sponsor tx execution result: success={}, tx_hash={:?}",
sponsor_result.is_ok(),
tx_hash
);

if let Ok(sponsor_ok) = sponsor_result {
// Log detailed sponsor transaction results
trace!(
"Sponsor tx details: tx_hash={:?}, gas_used={}, success={}, coinbase_profit={}, nonce_updated=({:?}, {})",
tx_hash,
sponsor_ok.tx_info.gas_used,
sponsor_ok.tx_info.receipt.success,
sponsor_ok.tx_info.coinbase_profit,
sponsor_ok.nonce_updated.0,
sponsor_ok.nonce_updated.1
);

// Check if sponsor transaction actually succeeded
if !sponsor_ok.tx_info.receipt.success {
trace!(
"Sponsorship declined - sponsor tx reverted: tx_hash={:?}, sender={:?}, shortfall={} wei, sponsor_gas_used={}, sponsor_logs={:?}",
tx_hash,
sender,
shortfall,
sponsor_ok.tx_info.gas_used,
sponsor_ok.tx_info.receipt.logs
);
self.rollback(rollback_point.clone());
continue;
}

// Now execute the original transaction.
let retry_result = self.commit_tx(
tx_with_blobs,
sponsor_ok.cumulative_gas_used,
gas_reserved,
sponsor_ok.cumulative_blob_gas_used,
)?;

if let Ok(res) = retry_result {
// Ensure bundle stays profitable after sponsorship.
let current_coinbase_balance = self.coinbase_balance()?;
let bundle_profit_so_far = current_coinbase_balance
.saturating_sub(coinbase_balance_before);

if bundle_profit_so_far >= total_sponsor_amount {
if !res.tx_info.receipt.success {
if bundle.dropping_tx_hashes.contains(&tx_hash) {
// Sponsorship successful but tx reverted (dropping allowed)
self.rollback(rollback_point.clone());
continue;
}
if !bundle.reverting_tx_hashes.contains(&tx_hash) {
// Sponsorship successful but tx reverted (not allowed)
return Ok(Err(BundleErr::TransactionReverted(
tx_hash,
)));
}
}

// First the sponsor tx, then the user tx.
Self::accumulate_tx_execution(sponsor_ok, &mut insert);
// Extract values before moving res
let tx_success = res.tx_info.receipt.success;
let tx_coinbase_profit = res.tx_info.coinbase_profit;
Self::accumulate_tx_execution(res, &mut insert);

// Update refundable_profit based on ACTUAL transaction profits, not optimistic bundle profit
// Only add profit from the original transaction if it's refundable and successful
if bundle.is_tx_refundable(&tx_hash)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if all of these can be simplified dramatically. Currently it adds a new branch for committing sponsored txs while it should be possible to do it in the same way so things like coinbase profit and refundable profit are handled by the same code that its handled now. Otherwise its a bit hard to verify that its correct.

For example, (see comment about continue above) I think this approach can lead to part of the bundle being committed while we should revert that bundle instead.

In other words, better approach would be so the sponsorhip path hits let result = self.commit_tx(

@quasarwin quasarwin Aug 25, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So there should be no issues with part of the bundle being committed, see the above response. If a sponsorship tx added prior to an underfunded tx doesn't cause a successful tx (along with profit), then it rolls back to the point before the sponsorship attempt and proceeds normally, meaning the underfunded transaction will fail, causing the bundle to fail unless its in reverting/dropping hashes.

So I just realized I am looking at the code on our fork, not on this fork. This code is a fork from develop branch and I copied things over to this without double checking or testing it. Our fork has diverted significantly and the branching is different. So yes, I think as is this will cause a problem.

The main goal with this approach was to prevent simulation overhead. If we wait until LackOfFundForGasLimit, then we'll have to create a sponsorship transactions before the tx, then retry the tx, and this can happen multiple times in a bundle. There are a lot of LackOfFundForGasLimit simulation errors and the vast majority of them are not profitable to sponsor, so when we tested it, it did significantly increase simulation load with almost no benefit.

We also try not to make too many architecturally significant changes, that way future merges are easier for us. Because of that, we tend to prefer more code duplication rather than more cross cutting changes, as long as it doesn't affect the feature or the performance. So if you guys feel like there's a better long term architecture here, that makes sense, and we'd probably merge that in and help support it moving forward.

&& tx_success
&& tx_coinbase_profit.is_positive()
{
refundable_profit += tx_coinbase_profit.unsigned_abs();
}

// Log successful sponsorship
trace!(
"Sponsorship successful: tx_hash={:?}, sender={:?}, total_sponsor_amount={} wei, shortfall={} wei, bundle_profit_so_far={} wei, tx_success={}, sponsor_address={:?}",
tx_hash,
sender,
total_sponsor_amount,
shortfall,
bundle_profit_so_far,
tx_success,
sponsor_signer.address
);

continue; // tx handled, go to next one.
} else {
// Not profitable – revert sponsor tx.
self.rollback(rollback_point.clone());
}
} else {
// Retry failed – revert sponsor tx.
self.rollback(rollback_point.clone());
}
}
}
}
}
}
// ------- end fast sponsorship pre-check -----------------------------

let result = self.commit_tx(
tx_with_blobs,
insert.cumulative_gas_used,
Expand Down
71 changes: 69 additions & 2 deletions crates/rbuilder/src/building/testing/bundle_tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ use std::collections::{HashMap, HashSet};

use crate::{
building::{
testing::bundle_tests::setup::NonceValue, BuiltBlockTrace, BundleErr, ExecutionResult,
OrderErr, TransactionErr,
testing::bundle_tests::setup::NonceValue, BuiltBlockTrace, BundleErr, ExecutionError,
ExecutionResult, OrderErr, TransactionErr,
},
primitives::{Bundle, BundleRefund, Order, OrderId, Refund, RefundConfig, TxRevertBehavior},
utils::{constants::BASE_TX_GAS, int_percentage},
Expand Down Expand Up @@ -966,3 +966,70 @@ fn test_original_order_id() -> eyre::Result<()> {

Ok(())
}

#[test]
fn test_sponsored_transaction() -> eyre::Result<()> {
let target_block = 11;
let mut test_setup = TestSetup::gen_test_setup(BlockArgs::default().number(target_block))?;

test_setup.begin_bundle_order(target_block);

let sender1 = NamedAddr::User(1);

let large_value_to_coinbase = 999_999_999_999_999_999_u64; // Just under 1 ETH but gas will push it over

Copilot AI Aug 6, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic number should be replaced with a named constant or calculated value. Consider using a more explicit calculation like U256::from(10).pow(U256::from(18)) - U256::from(1) to make the intent clearer.

Suggested change
let large_value_to_coinbase = 999_999_999_999_999_999_u64; // Just under 1 ETH but gas will push it over
// Just under 1 ETH but gas will push it over
let large_value_to_coinbase = U256::from(10).pow(U256::from(18)) - U256::from(1);

Copilot uses AI. Check for mistakes.
test_setup.add_send_to_coinbase_tx(sender1, large_value_to_coinbase)?;

let result = test_setup.try_commit_order();

match result {
Ok(Ok(execution_result)) => {
let builder_addr = test_setup.named_address(NamedAddr::Builder)?;
let sponsor_txs: Vec<_> = execution_result
.tx_infos
.iter()
.filter(|tx_info| tx_info.tx.signer() == builder_addr)
.collect();

// Verify sponsorship occurred - should have a sponsor transaction
assert_eq!(
sponsor_txs.len(),
1,
"Expected exactly 1 sponsor transaction"
);

// Verify we have both sponsor tx and original tx
assert_eq!(
execution_result.tx_infos.len(),
2,
"Expected sponsor tx + original tx"
);

// Verify the bundle was profitable
assert!(
execution_result.coinbase_profit > U256::ZERO,
"Sponsored bundle should be profitable"
);
}
Ok(Err(ExecutionError::OrderError(OrderErr::Transaction(
TransactionErr::InvalidTransaction(invalid_tx),
)))) => {
let error_str = format!("{:?}", invalid_tx);
// LackOfFundForMaxFee indicates sponsorship logic failed
if error_str.contains("LackOfFundForMaxFee") {
panic!("Sponsorship failed: {}", error_str);
}
panic!("Unexpected transaction error: {}", error_str);
}
Ok(Err(ExecutionError::OrderError(OrderErr::NegativeProfit(loss)))) => {
panic!("Unexpected negative profit: {} wei", loss);
}
Ok(Err(other_execution_error)) => {
panic!("Unexpected execution error: {:?}", other_execution_error);
}
Err(eyre_error) => {
panic!("Critical error: {:?}", eyre_error);
}
}

Ok(())
}
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ impl TestSetup {
current_value,
)
}
fn try_commit_order(&mut self) -> eyre::Result<Result<ExecutionResult, ExecutionError>> {
pub fn try_commit_order(&mut self) -> eyre::Result<Result<ExecutionResult, ExecutionError>> {
let state_provider: Arc<dyn StateProvider> =
Arc::from(self.test_chain.provider_factory().latest()?);
let mut local_ctx = ThreadBlockBuildingContext::default();
Expand Down
Loading