Перейти к основному содержимому
EngineeringMar 28, 2026

Deep EVM #16: Bundling and Conflict Resolution — Packing Profitable Transactions

OS
Open Soft Team

Engineering Team

Why Bundling Matters

A single arbitrage transaction is straightforward: simulate it, submit it, profit. But a production MEV bot finds dozens of profitable cycles per block. Each cycle produces one transaction. Submitting them individually is wasteful and risky:

  1. Gas overhead: Each transaction pays a 21,000 base gas cost. Bundling N swaps into one transaction pays the base cost once.
  2. Atomicity: If transaction A and transaction B both touch the same pool, A’s execution changes the state that B depends on. Submitted separately, B may revert. Bundled together, you can order them correctly.
  3. Builder preference: Block builders prefer larger bundles that pay more total MEV — your bundle competes for inclusion against other searchers’ bundles.

Conflict Detection: The Bitmask Approach

Two arbitrage cycles conflict if they share a pool. When cycle A swaps through Pool X, it changes Pool X’s reserves. Cycle B, which also uses Pool X, was simulated against the old reserves and will produce a different (likely worse) outcome.

We detect conflicts using bitmasks. Assign each pool an index (0 to N-1). Each cycle gets a bitmask indicating which pools it touches:

use bitvec::prelude::*;

#[derive(Clone)]
pub struct CycleWithMask {
    pub cycle: ProfitableCycle,
    pub pool_mask: BitVec,
}

pub fn build_pool_masks(
    cycles: &[ProfitableCycle],
    pool_index: &HashMap<Address, usize>,
    total_pools: usize,
) -> Vec<CycleWithMask> {
    cycles
        .iter()
        .map(|c| {
            let mut mask = bitvec![0; total_pools];
            for pool in &c.cycle.pools {
                if let Some(&idx) = pool_index.get(&pool.address) {
                    mask.set(idx, true);
                }
            }
            CycleWithMask {
                cycle: c.clone(),
                pool_mask: mask,
            }
        })
        .collect()
}

Two cycles conflict if their bitmasks have any bit in common:

fn conflicts(a: &BitVec, b: &BitVec) -> bool {
    // Bitwise AND: if any bit is set in both, they conflict
    a.iter()
        .zip(b.iter())
        .any(|(x, y)| *x && *y)
}

This is O(N/64) per comparison — each 64-bit word is checked with a single AND instruction. For 80,000 pools, the bitmask is 10 KB. Comparing two masks takes ~150 AND operations. This is orders of magnitude faster than iterating through pool address lists.

Greedy Bundle Packing

Given a set of profitable cycles, find the maximum-profit conflict-free subset. This is a weighted maximum independent set problem on a conflict graph — NP-hard in general. We use a greedy approximation:

pub fn pack_bundle(
    candidates: &mut Vec<CycleWithMask>,
) -> Vec<CycleWithMask> {
    // Sort by profit descending
    candidates.sort_by(|a, b| {
        b.cycle.expected_profit.cmp(&a.cycle.expected_profit)
    });

    let total_pools = candidates
        .first()
        .map(|c| c.pool_mask.len())
        .unwrap_or(0);

    let mut bundle: Vec<CycleWithMask> = Vec::new();
    let mut used_pools = bitvec![0; total_pools];

    for candidate in candidates.iter() {
        // Check if this candidate conflicts with any already-selected cycle
        if conflicts(&candidate.pool_mask, &used_pools) {
            continue;
        }

        // No conflict — add to bundle
        used_pools |= &candidate.pool_mask;
        bundle.push(candidate.clone());
    }

    bundle
}

The greedy approach selects the most profitable cycle first, marks its pools as used, then selects the next most profitable non-conflicting cycle, and so on. It does not find the global optimum, but it runs in O(N * P/64) time where N is the number of candidates and P is the number of pools. In practice, the greedy solution is within 5-10% of optimal.

When Greedy Fails

Consider: Cycle A profits 1 ETH and touches pools {1, 2, 3}. Cycles B and C each profit 0.6 ETH and touch pools {1} and {2, 3} respectively. Greedy picks A (1 ETH). Optimal picks B + C (1.2 ETH).

For a more accurate solution, use branch-and-bound or dynamic programming on the conflict graph. But in production, the greedy approach is fast and good enough — the simulation estimates have uncertainty anyway.

Re-Simulation After Packing

After selecting the bundle, re-simulate the entire sequence in order. The first cycle executes against the current state. The second cycle must be simulated against the state after the first cycle executes:

pub fn validate_bundle(
    simulator: &mut Simulator,
    bundle: &[CycleWithMask],
) -> Vec<ValidatedTx> {
    let mut validated = Vec::new();

    // Snapshot the current DB state
    let snapshot = simulator.snapshot();

    for entry in bundle {
        let calldata = build_execution_calldata(
            &entry.cycle,
            entry.cycle.optimal_input,
        );

        let result = simulator.simulate_swap(
            bot_address(),
            bot_contract(),
            calldata.clone(),
            U256::ZERO,
        );

        match result {
            Ok(sim) if sim.success => {
                // Commit this transaction's state changes
                // (the CacheDB now reflects post-swap state)
                validated.push(ValidatedTx {
                    calldata,
                    gas_used: sim.gas_used,
                    expected_profit: entry.cycle.expected_profit,
                });
            }
            _ => {
                // This cycle is no longer profitable after prior swaps
                // changed the state. Skip it.
                tracing::debug!(
                    "Cycle {:?} failed during bundle validation",
                    entry.cycle.cycle.id
                );
            }
        }
    }

    // Restore snapshot (don't persist simulation state)
    simulator.restore(snapshot);

    validated
}

Cycles that were individually profitable may fail during bundle validation because an earlier cycle in the bundle altered the pool state they depend on. This is expected — the conflict detection is conservative (pools not in common are considered non-conflicting) but does not account for indirect effects like token balance changes.

Tiered Batching Strategies

Different MEV opportunities have different urgency and competition levels. Use a tiered approach:

Tier 1: Event-Driven (Reactive)

Triggered by mempool transactions or on-chain events. When you see a large swap in the mempool, immediately simulate back-run arbitrage opportunities. Latency is critical — you are competing with other searchers who see the same transaction.

pub async fn on_pending_tx(
    &mut self,
    tx: Transaction,
) {
    // Decode the transaction
    let affected_pools = identify_affected_pools(&tx);
    if affected_pools.is_empty() { return; }

    // Simulate state after this tx
    let mut fork = self.simulator.fork();
    fork.apply_transaction(&tx);

    // Find arbitrage in the post-tx state
    let cycles = self.cycle_index.get_cycles_for_pools(&affected_pools);

    for cycle in cycles {
        if let Some(arb) = find_optimal_input(&mut fork, cycle, ...) {
            self.submit_backrun(tx.hash, arb).await;
        }
    }
}

Tier 2: Block-Based (Rotation)

At the start of each block, re-evaluate all recently profitable cycles and a rotating subset of all cycles. This catches opportunities from state changes you did not explicitly observe (direct transfers, oracle updates, governance actions).

Tier 3: Top-N Scouting

Maintain a “top N” list of highest-liquidity pools. Every K blocks, do a full DFS from scratch on the top-N pool subgraph to discover new cycles. This adapts to pool creation and removal.

Bundle Submission

Flashbots Bundles

The standard submission path. A bundle is an ordered list of signed transactions submitted to a Flashbots-compatible builder:

use ethers_flashbots::{FlashbotsMiddleware, BundleRequest};

pub async fn submit_flashbots_bundle(
    client: &FlashbotsMiddleware<Provider<Http>, LocalWallet>,
    transactions: Vec<Bytes>,
    target_block: u64,
) -> Result<BundleHash> {
    let mut bundle = BundleRequest::new();

    for tx in transactions {
        bundle = bundle.push_transaction(tx);
    }

    bundle = bundle
        .set_block(target_block)
        .set_simulation_block(target_block - 1)
        .set_simulation_timestamp(0);  // use block timestamp

    let result = client
        .send_bundle(&bundle)
        .await?;

    Ok(result.bundle_hash)
}

Flashbots bundles have a key property: all-or-nothing execution. Either every transaction in the bundle succeeds, or none are included. This prevents partial execution where the first swap succeeds but the second reverts, leaving you with an intermediate token you did not want.

MEV-Share

MEV-Share is Flashbots’ protocol for redistributing MEV to users. When a user submits a transaction through Flashbots Protect, searchers can bid to back-run it. The user receives a share of the MEV.

To participate as a searcher:

pub async fn submit_mev_share_bundle(
    client: &MevShareClient,
    user_tx_hash: H256,
    backrun_tx: Bytes,
    refund_percent: u64,  // % of profit to refund to user
) -> Result<()> {
    let bundle = MevShareBundle {
        inclusion: Inclusion {
            block: target_block,
            max_block: target_block + 2,
        },
        body: vec![
            BundleItem::HashItem { hash: user_tx_hash },
            BundleItem::TxItem {
                tx: backrun_tx,
                can_revert: false,
            },
        ],
        validity: Validity {
            refund: vec![Refund {
                body_idx: 0,
                percent: refund_percent,
            }],
        },
    };

    client.send_bundle(bundle).await?;
    Ok(())
}

MEV-Share bundles reference the user’s transaction by hash. The builder places your back-run immediately after the user’s transaction.

Direct Builder APIs

Some builders accept bundles directly, bypassing Flashbots:

  • Titan Builder: https://rpc.titanbuilder.xyz
  • BeaverBuild: https://rpc.beaverbuild.org
  • Rsync Builder: https://rsync-builder.xyz
pub async fn submit_to_builders(
    bundles: &[SignedBundle],
    builders: &[BuilderEndpoint],
) {
    // Submit to ALL builders simultaneously
    let futures: Vec<_> = builders
        .iter()
        .map(|builder| {
            submit_bundle_to_builder(builder, bundles)
        })
        .collect();

    let results = futures::future::join_all(futures).await;

    for (builder, result) in builders.iter().zip(results) {
        match result {
            Ok(_) => tracing::info!("Submitted to {}", builder.name),
            Err(e) => tracing::warn!("Failed {}: {}", builder.name, e),
        }
    }
}

Always submit to multiple builders. You are competing for block inclusion — the more builders see your bundle, the higher the probability it gets included.

Coinbase Transfer: Paying the Builder

MEV bundles do not pay for inclusion via gas price. Instead, the last transaction in the bundle sends a direct ETH transfer to block.coinbase (the builder’s fee recipient):

#define macro PAY_COINBASE() = takes(1) returns(0) {
    // takes: [payment_amount]
    0x00 0x00 0x00 0x00     // retSize, retOff, argSize, argOff
    swap4                   // [amount, 0, 0, 0, 0]
    coinbase                // [coinbase, amount, 0, 0, 0, 0]
    gas call                // [success]
    pop
}

The payment amount is typically 90-99% of the expected profit. The searcher keeps 1-10% as their margin. Higher payments increase inclusion probability but reduce profit. This is a constant game-theoretic balance.

End-to-End Flow

Putting it all together, here is the per-block flow of a production MEV bot:

1. New block arrives (t=0)
2. Update pool reserves from events (t=0-500ms)
3. Identify affected cycles via cycle index (t=500ms-1s)
4. Simulate affected cycles + rotating batch (t=1-4s)
   - Binary search for optimal inputs
   - Deadline-aware: highest profit first
5. Build bitmasks, pack conflict-free bundle (t=4-5s)
6. Re-simulate bundle in sequence (t=5-6s)
7. Sign transactions, compute coinbase payment (t=6-7s)
8. Submit to Flashbots + direct builders (t=7-8s)
9. Wait for inclusion (t=8-12s)
10. Log results, update profit tracking (t=12s)

The entire pipeline runs within the 12-second block time. Every millisecond counts.

Monitoring and Observability

Production MEV bots need comprehensive monitoring:

#[derive(Default)]
pub struct BlockMetrics {
    pub cycles_evaluated: u64,
    pub cycles_profitable: u64,
    pub bundles_submitted: u64,
    pub bundles_included: u64,
    pub total_profit_wei: U256,
    pub total_gas_cost_wei: U256,
    pub simulation_time_ms: u64,
    pub deadline_expired: bool,
}

Track inclusion rate (bundles_included / bundles_submitted), average profit per included bundle, and deadline misses. A healthy bot has >20% inclusion rate and <5% deadline misses.

Summary

Bundling transforms individual arbitrage transactions into optimized, conflict-free packages that maximize profit per block. Bitmask conflict detection runs in microseconds. Greedy packing finds near-optimal bundles. Re-simulation validates the bundle against realistic state. Multi-builder submission maximizes inclusion probability. This completes our Deep EVM series — from raw opcodes in Huff to production MEV infrastructure. The stack: Huff contracts for on-chain execution, Rust for off-chain simulation and bundling, and the Flashbots ecosystem for fair block inclusion.