Skip to main content

evm_fork_cache/
bundle.rs

1//! Multi-transaction **bundle simulation** over cumulative block state, plus
2//! **coinbase / miner-payment accounting** (Phase 6 Track A+B).
3//!
4//! A *bundle* is an ordered sequence of `Call`-kind transactions applied to a
5//! single overlay so that transaction `i` observes the committed writes of
6//! transactions `0..i` — the minimal primitive an MEV searcher needs to value a
7//! candidate set of transactions as a unit. It is intentionally *not* a block
8//! builder: there is no mempool, ordering auction, or `Create`-kind support (see
9//! the Phase 6 spec non-goals).
10//!
11//! The execution itself lives in [`EvmOverlay::simulate_bundle`] (and the
12//! cache-side convenience [`EvmCache::simulate_bundle`]); this module owns the
13//! public vocabulary those methods speak.
14//!
15//! # Coinbase accounting
16//!
17//! [`BundleResult::coinbase_payment`] is the block beneficiary's balance delta
18//! across the bundle — the honest miner payment. Under EIP-1559 (London+, which
19//! this engine runs by default) revm credits the beneficiary only the **priority
20//! fee** (`(effective_gas_price − basefee) × gas_used`) and burns the base-fee
21//! portion in-EVM, so the delta already excludes the base fee. It also captures
22//! any **direct value transfers to the beneficiary** (an explicit coinbase tip).
23//! So `coinbase_payment = Σ priority_feeᵢ × gas_usedᵢ + direct coinbase tips`,
24//! over the transactions whose effects are kept. Set the base fee with
25//! [`EvmCache::set_basefee`](crate::cache::EvmCache::set_basefee) to model a
26//! non-zero base fee (a higher base fee lowers the priority fee, and thus the
27//! payment, for a fixed `gas_price`). All arithmetic is saturating.
28//!
29//! [`EvmCache::simulate_bundle`]: crate::cache::EvmCache::simulate_bundle
30//! [`EvmOverlay::simulate_bundle`]: crate::cache::EvmOverlay::simulate_bundle
31
32use alloy_primitives::{Address, Bytes, Log, U256};
33use revm::context::result::ExecutionResult;
34
35use crate::cache::TxConfig;
36
37/// One transaction in a bundle.
38///
39/// `Call`-kind only for Phase 6 (`Create`/`Create2` bundle transactions are a
40/// documented follow-up). The [`tx`](BundleTx::tx) field reuses the existing
41/// [`TxConfig`] vocabulary (`value` / `gas_limit` / `gas_price` / `nonce` /
42/// `access_list`), so a bundle transaction can carry native value, be
43/// gas-bounded, or pre-warm an EIP-2930 access list exactly like a single
44/// [`call_raw_with_access_list_with`](crate::cache::EvmOverlay::call_raw_with_access_list_with)
45/// call.
46#[derive(Clone, Debug)]
47pub struct BundleTx {
48    /// Sender of the call (the `from` / caller address).
49    pub from: Address,
50    /// Call target (`Call`-kind only for Phase 6).
51    pub to: Address,
52    /// ABI-encoded calldata for the call.
53    pub calldata: Bytes,
54    /// Per-transaction environment overrides (value / gas / nonce / access list).
55    pub tx: TxConfig,
56}
57
58impl BundleTx {
59    /// A bundle transaction with a default [`TxConfig`] (zero value, default
60    /// gas/nonce, no access list).
61    pub fn new(from: Address, to: Address, calldata: Bytes) -> Self {
62        Self {
63            from,
64            to,
65            calldata,
66            tx: TxConfig::default(),
67        }
68    }
69
70    /// A bundle transaction carrying an explicit [`TxConfig`] — e.g. to send
71    /// native `value` (a direct coinbase transfer), set a `gas_price`, or
72    /// pre-warm an access list.
73    pub fn with_config(from: Address, to: Address, calldata: Bytes, tx: TxConfig) -> Self {
74        Self {
75            from,
76            to,
77            calldata,
78            tx,
79        }
80    }
81}
82
83/// What happens when a bundle transaction reverts (or halts).
84///
85/// Defaults to [`Atomic`](RevertPolicy::Atomic).
86#[derive(Clone, Debug, Default)]
87pub enum RevertPolicy {
88    /// Any transaction revert/halt reverts the **whole** bundle to the outer
89    /// checkpoint and sets [`BundleResult::succeeded`] to `false`. Execution
90    /// stops at the failing transaction.
91    #[default]
92    Atomic,
93    /// The listed transaction indices may revert without aborting the bundle:
94    /// their state effects are rolled back **individually** (inner checkpoint
95    /// revert) and later transactions still execute. A revert at an index *not*
96    /// in the list behaves like [`Atomic`](RevertPolicy::Atomic).
97    AllowReverts(Vec<usize>),
98}
99
100/// Options controlling a bundle simulation.
101#[derive(Clone, Debug, Default)]
102pub struct BundleOptions {
103    /// Revert handling for individual transactions. Default
104    /// [`Atomic`](RevertPolicy::Atomic).
105    pub revert_policy: RevertPolicy,
106    /// Whether the bundle's cumulative state is folded into the overlay's dirty
107    /// layer (`true`) or reverted so the overlay is left unchanged (`false`).
108    /// Default `false` (evaluate, don't persist).
109    pub commit: bool,
110}
111
112/// Outcome of a single transaction executed within a bundle.
113#[derive(Clone, Debug)]
114pub struct TxOutcome {
115    /// The raw revm [`ExecutionResult`] (`Success` / `Revert` / `Halt`).
116    pub result: ExecutionResult,
117    /// Gas consumed by this transaction.
118    pub gas_used: u64,
119    /// `true` if this transaction reverted or halted.
120    pub reverted: bool,
121    /// Logs emitted by this transaction (empty for a revert/halt).
122    pub logs: Vec<Log>,
123}
124
125/// Result of a bundle simulation.
126#[derive(Clone, Debug)]
127pub struct BundleResult {
128    /// One [`TxOutcome`] per executed transaction. Length equals `txs.len()`
129    /// unless an [`Atomic`](RevertPolicy::Atomic) bundle aborted early, in which
130    /// case it ends at the failing transaction.
131    pub per_tx: Vec<TxOutcome>,
132    /// Miner payment: the block beneficiary's balance delta across the kept
133    /// transactions (priority fee + direct coinbase tips; the base fee is already
134    /// excluded by revm — see the [module docs](self#coinbase-accounting)).
135    /// Saturating; `0` for an [`Atomic`](RevertPolicy::Atomic) bundle that aborted.
136    ///
137    /// Under [`AllowReverts`](RevertPolicy::AllowReverts) a whitelisted tx that
138    /// reverts contributes **nothing** to this figure (its beneficiary credit is
139    /// rolled back with the rest of its effects), even though it still burned gas.
140    /// A searcher's net cost therefore also includes that wasted gas:
141    /// `coinbase_payment + reverted_tx_gas` approximates net searcher cost under
142    /// `AllowReverts`.
143    pub coinbase_payment: U256,
144    /// Total gas used across **all** executed transactions — successful and
145    /// reverted alike. Equal to `successful_tx_gas + reverted_tx_gas`. Saturating.
146    pub gas_used: u64,
147    /// Sum of [`gas_used`](TxOutcome::gas_used) over the executed transactions that
148    /// did **not** revert (`!reverted`). This is the gas backing
149    /// [`coinbase_payment`](Self::coinbase_payment). Saturating.
150    pub successful_tx_gas: u64,
151    /// Sum of [`gas_used`](TxOutcome::gas_used) over the executed transactions that
152    /// **reverted** (`reverted`). Under [`AllowReverts`](RevertPolicy::AllowReverts)
153    /// this is the gas a whitelisted revert wasted without contributing to
154    /// [`coinbase_payment`](Self::coinbase_payment); a searcher recovers it here
155    /// instead of iterating [`per_tx`](Self::per_tx). Saturating.
156    pub reverted_tx_gas: u64,
157    /// `false` iff an [`Atomic`](RevertPolicy::Atomic) bundle aborted on a
158    /// revert/halt (or an `AllowReverts` bundle hit a revert at a non-whitelisted
159    /// index).
160    pub succeeded: bool,
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn revert_policy_defaults_to_atomic() {
169        assert!(matches!(RevertPolicy::default(), RevertPolicy::Atomic));
170    }
171
172    #[test]
173    fn bundle_options_default_is_evaluate_only_atomic() {
174        let opts = BundleOptions::default();
175        assert!(matches!(opts.revert_policy, RevertPolicy::Atomic));
176        assert!(!opts.commit, "default must not persist");
177    }
178
179    #[test]
180    fn bundle_tx_new_uses_default_tx_config() {
181        let from = Address::repeat_byte(0x01);
182        let to = Address::repeat_byte(0x02);
183        let tx = BundleTx::new(from, to, Bytes::from(vec![0xaa]));
184        assert_eq!(tx.from, from);
185        assert_eq!(tx.to, to);
186        assert_eq!(tx.calldata, Bytes::from(vec![0xaa]));
187        assert_eq!(tx.tx.value, U256::ZERO);
188        assert!(tx.tx.gas_price.is_none());
189        assert!(tx.tx.access_list.is_none());
190    }
191
192    #[test]
193    fn bundle_tx_with_config_carries_value_and_gas_price() {
194        let tx = BundleTx::with_config(
195            Address::ZERO,
196            Address::ZERO,
197            Bytes::new(),
198            TxConfig {
199                value: U256::from(42u64),
200                gas_price: Some(7),
201                ..Default::default()
202            },
203        );
204        assert_eq!(tx.tx.value, U256::from(42u64));
205        assert_eq!(tx.tx.gas_price, Some(7));
206    }
207}