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}