arb_evm/
state_overlay.rs

1use std::collections::HashMap;
2
3use alloy_primitives::Address;
4use revm::{Database, database::State};
5use revm_database::{AccountStatus as CacheAccountStatus, TransitionAccount};
6use revm_state::AccountInfo;
7
8#[derive(Clone, Debug)]
9struct Entry {
10    previous_info: Option<AccountInfo>,
11    previous_status: CacheAccountStatus,
12}
13
14/// Records pre-mutation snapshots so direct State-cache writes performed
15/// outside revm's normal transition flow can be committed back as
16/// transitions at end of tx.
17///
18/// Every captured account — freshly created or modified — is emitted as a
19/// manually-built [`TransitionAccount`] whose `previous_info` is the snapshot
20/// taken before the first write. This keeps the revert baseline equal to the
21/// parent-block state even though `apply_balance_op` pre-mutates the cache for
22/// within-tx visibility; a transient pre-write (e.g. a retry tx's prepaid-gas
23/// mint) must never become the account's changeset baseline.
24#[derive(Default, Debug)]
25pub struct StateOverlay {
26    entries: HashMap<Address, Entry>,
27}
28
29impl StateOverlay {
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    pub fn reset_tx(&mut self) {
35        self.entries.clear();
36    }
37
38    pub fn record_pre_touch<DB: Database>(&mut self, state: &mut State<DB>, addr: Address) {
39        if self.entries.contains_key(&addr) {
40            return;
41        }
42        let _ = state.load_cache_account(addr);
43        let cache_entry = state.cache.accounts.get(&addr);
44        let previous_info = cache_entry
45            .and_then(|c| c.account.as_ref())
46            .map(|a| a.info.clone());
47        let previous_status = cache_entry
48            .map(|c| c.status)
49            .unwrap_or(CacheAccountStatus::LoadedNotExisting);
50        self.entries.insert(
51            addr,
52            Entry {
53                previous_info,
54                previous_status,
55            },
56        );
57    }
58
59    pub fn drain_and_apply<DB: Database>(
60        &mut self,
61        state: &mut State<DB>,
62        zombies: &rustc_hash::FxHashSet<Address>,
63    ) {
64        if self.entries.is_empty() {
65            return;
66        }
67        let entries: Vec<(Address, Entry)> = self.entries.drain().collect();
68
69        let mut existing_transitions: Vec<(Address, TransitionAccount)> = Vec::new();
70
71        for (addr, entry) in entries {
72            let current_info = state
73                .cache
74                .accounts
75                .get(&addr)
76                .and_then(|c| c.account.as_ref())
77                .map(|a| a.info.clone());
78
79            if current_info == entry.previous_info {
80                continue;
81            }
82
83            let pre_empty = entry
84                .previous_info
85                .as_ref()
86                .map(|i| i.is_empty())
87                .unwrap_or(true);
88            let cur_empty = current_info.as_ref().map(|i| i.is_empty()).unwrap_or(true);
89            if pre_empty && cur_empty {
90                // A present-empty result is normally pruned (EIP-161). An account
91                // resurrected this block by a zero-value transfer on pre-Stylus
92                // ArbOS must instead persist as a present-empty leaf. Its revert
93                // baseline is the genuinely absent parent state: a snapshot taken
94                // from a destructed cache entry can carry a stale non-empty
95                // status, so it is normalised to LoadedNotExisting, which reverts
96                // to "absent" rather than to a spurious present-empty account.
97                if zombies.contains(&addr) && current_info.is_some() {
98                    let previous_status = if entry.previous_info.is_none() {
99                        CacheAccountStatus::LoadedNotExisting
100                    } else {
101                        entry.previous_status
102                    };
103                    if let Some(cached) = state.cache.accounts.get_mut(&addr) {
104                        cached.status = CacheAccountStatus::InMemoryChange;
105                    }
106                    existing_transitions.push((
107                        addr,
108                        TransitionAccount {
109                            info: current_info.clone(),
110                            status: CacheAccountStatus::InMemoryChange,
111                            previous_info: entry.previous_info,
112                            previous_status,
113                            storage: Default::default(),
114                            storage_was_destroyed: false,
115                        },
116                    ));
117                }
118                continue;
119            }
120
121            // The emitted transition must be a valid successor of the status
122            // the bundle already holds for this account. Within a multi-block
123            // batch the cache can be evicted while the bundle keeps an account
124            // as `Changed`/`InMemoryChange`; the cache snapshot alone is not
125            // authoritative, so prefer the bundle status when present.
126            let base_status = state
127                .bundle_state
128                .state
129                .get(&addr)
130                .map(|b| b.status)
131                .unwrap_or(entry.previous_status);
132            let live_in_bundle = state
133                .bundle_state
134                .state
135                .get(&addr)
136                .is_some_and(|b| b.info.is_some());
137
138            let was_non_existing = !live_in_bundle
139                && (entry.previous_info.is_none()
140                    || matches!(
141                        base_status,
142                        CacheAccountStatus::LoadedNotExisting
143                            | CacheAccountStatus::LoadedEmptyEIP161
144                    ));
145
146            if was_non_existing && !cur_empty {
147                // Insert the new account via an explicit transition whose
148                // baseline is the recorded pre-existing state (absent for a
149                // freshly-seen account). Committing through revm's create path
150                // would capture the transient pre-mutation — e.g. a retry tx's
151                // prepaid-gas mint written only for within-tx visibility — as
152                // the revert baseline, corrupting the account changeset and the
153                // incremental-merkle trie.
154                if let Some(cached) = state.cache.accounts.get_mut(&addr) {
155                    cached.status = CacheAccountStatus::InMemoryChange;
156                }
157                existing_transitions.push((
158                    addr,
159                    TransitionAccount {
160                        info: current_info.clone(),
161                        status: CacheAccountStatus::InMemoryChange,
162                        previous_info: entry.previous_info,
163                        previous_status: entry.previous_status,
164                        storage: Default::default(),
165                        storage_was_destroyed: false,
166                    },
167                ));
168                continue;
169            }
170
171            // Non-empty result keeps the account live: map the base status to
172            // its modified successor. Empty result deletes it (EIP-161).
173            let new_status = if cur_empty {
174                match base_status {
175                    CacheAccountStatus::LoadedNotExisting => continue,
176                    CacheAccountStatus::DestroyedAgain | CacheAccountStatus::DestroyedChanged => {
177                        CacheAccountStatus::DestroyedAgain
178                    }
179                    _ => CacheAccountStatus::Destroyed,
180                }
181            } else {
182                match base_status {
183                    CacheAccountStatus::Loaded => CacheAccountStatus::Changed,
184                    CacheAccountStatus::LoadedNotExisting
185                    | CacheAccountStatus::LoadedEmptyEIP161 => CacheAccountStatus::InMemoryChange,
186                    CacheAccountStatus::DestroyedAgain
187                    | CacheAccountStatus::Destroyed
188                    | CacheAccountStatus::DestroyedChanged => CacheAccountStatus::DestroyedChanged,
189                    other => other,
190                }
191            };
192
193            let goes_destroyed = matches!(
194                new_status,
195                CacheAccountStatus::Destroyed | CacheAccountStatus::DestroyedAgain
196            );
197            let transition_info = if goes_destroyed {
198                None
199            } else {
200                current_info.clone()
201            };
202            let storage_was_destroyed = goes_destroyed && !pre_empty;
203
204            if let Some(cached) = state.cache.accounts.get_mut(&addr) {
205                cached.status = new_status;
206                if goes_destroyed {
207                    cached.account = None;
208                }
209            }
210
211            existing_transitions.push((
212                addr,
213                TransitionAccount {
214                    info: transition_info,
215                    status: new_status,
216                    previous_info: entry.previous_info,
217                    previous_status: entry.previous_status,
218                    storage: Default::default(),
219                    storage_was_destroyed,
220                },
221            ));
222        }
223
224        if !existing_transitions.is_empty() {
225            state.apply_transition(existing_transitions);
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use alloy_primitives::{U256, address};
233    use revm::database::{EmptyDB, State};
234    use revm_database::states::{
235        bundle_state::BundleRetention, cache_account::CacheAccount, plain_account::PlainAccount,
236    };
237
238    use super::*;
239
240    fn make_state() -> State<EmptyDB> {
241        State::builder()
242            .with_database(EmptyDB::default())
243            .with_bundle_update()
244            .build()
245    }
246
247    #[test]
248    fn fresh_account_credit_lands_in_persisted_bundle() {
249        let mut state = make_state();
250        let mut overlay = StateOverlay::new();
251        let recipient = address!("000000000000000000000000000000000000beef");
252        let credit = U256::from(0x6a94d74f430000u64);
253
254        overlay.record_pre_touch(&mut state, recipient);
255        let entry = state.cache.accounts.get_mut(&recipient).unwrap();
256        entry.account = Some(PlainAccount {
257            info: AccountInfo {
258                balance: credit,
259                ..Default::default()
260            },
261            storage: Default::default(),
262        });
263
264        overlay.drain_and_apply(&mut state, &Default::default());
265        state.merge_transitions(BundleRetention::Reverts);
266
267        let bundled = state
268            .bundle_state
269            .state
270            .get(&recipient)
271            .and_then(|a| a.info.as_ref())
272            .map(|i| i.balance);
273        assert_eq!(bundled, Some(credit));
274    }
275
276    #[test]
277    fn existing_account_change_lands_in_persisted_bundle() {
278        let mut state = make_state();
279        let mut overlay = StateOverlay::new();
280        let acct = address!("000000000000000000000000000000000000c0de");
281
282        state.cache.accounts.insert(
283            acct,
284            CacheAccount {
285                account: Some(PlainAccount {
286                    info: AccountInfo {
287                        balance: U256::from(10u64),
288                        ..Default::default()
289                    },
290                    storage: Default::default(),
291                }),
292                status: CacheAccountStatus::Loaded,
293            },
294        );
295
296        overlay.record_pre_touch(&mut state, acct);
297        if let Some(p) = state
298            .cache
299            .accounts
300            .get_mut(&acct)
301            .and_then(|c| c.account.as_mut())
302        {
303            p.info.balance = U256::from(42u64);
304        }
305
306        overlay.drain_and_apply(&mut state, &Default::default());
307        state.merge_transitions(BundleRetention::Reverts);
308
309        let bundled = state
310            .bundle_state
311            .state
312            .get(&acct)
313            .and_then(|a| a.info.as_ref())
314            .map(|i| i.balance);
315        assert_eq!(bundled, Some(U256::from(42u64)));
316    }
317
318    fn credit<DB: Database>(
319        state: &mut State<DB>,
320        overlay: &mut StateOverlay,
321        a: Address,
322        by: U256,
323    ) {
324        overlay.record_pre_touch(state, a);
325        let c = state.cache.accounts.get_mut(&a).unwrap();
326        match c.account.as_mut() {
327            Some(acct) => acct.info.balance += by,
328            None => {
329                c.account = Some(PlainAccount {
330                    info: AccountInfo {
331                        balance: by,
332                        ..Default::default()
333                    },
334                    storage: Default::default(),
335                })
336            }
337        }
338        overlay.drain_and_apply(state, &Default::default());
339        state.merge_transitions(BundleRetention::Reverts);
340        overlay.reset_tx();
341    }
342
343    #[test]
344    fn repro_fresh_credit_then_recredit_across_merges() {
345        let mut state = make_state();
346        let mut overlay = StateOverlay::new();
347        let a = address!("00000000000000000000000000000000feed0001");
348        credit(&mut state, &mut overlay, a, U256::from(1_000_000u64));
349        credit(&mut state, &mut overlay, a, U256::from(2_000_000u64));
350        let bal = state
351            .bundle_state
352            .state
353            .get(&a)
354            .and_then(|x| x.info.as_ref())
355            .map(|i| i.balance);
356        assert_eq!(bal, Some(U256::from(3_000_000u64)));
357    }
358
359    /// An account already established in the accumulated bundle (status
360    /// `Changed`) is re-credited after its cache entry has been evicted. The
361    /// overlay must emit a transition that is a valid successor of the bundle
362    /// status, not re-create it as `InMemoryChange` (which revm rejects).
363    #[test]
364    fn recredit_with_drifted_cache_status_stays_changed() {
365        let mut state = make_state();
366        let mut overlay = StateOverlay::new();
367        let a = address!("00000000000000000000000000000000feed0002");
368
369        // Pre-existing on-disk (Loaded) account credited once → bundle `Changed`.
370        state.cache.accounts.insert(
371            a,
372            CacheAccount {
373                account: Some(PlainAccount {
374                    info: AccountInfo {
375                        balance: U256::from(10u64),
376                        ..Default::default()
377                    },
378                    storage: Default::default(),
379                }),
380                status: CacheAccountStatus::Loaded,
381            },
382        );
383        credit(&mut state, &mut overlay, a, U256::from(1_000_000u64));
384        assert_eq!(
385            state.bundle_state.state.get(&a).map(|b| b.status),
386            Some(CacheAccountStatus::Changed)
387        );
388
389        // The cache status drifts to an "empty/non-existing" marker (e.g. via
390        // EIP-161 touch handling) while the account and the bundle keep it as a
391        // live `Changed` entry. Pre-fix this drove the overlay to emit an
392        // `InMemoryChange`/created transition, which revm rejects.
393        state.cache.accounts.get_mut(&a).unwrap().status = CacheAccountStatus::LoadedEmptyEIP161;
394
395        credit(&mut state, &mut overlay, a, U256::from(2_000_000u64));
396        let acct = state.bundle_state.state.get(&a).unwrap();
397        assert_eq!(acct.status, CacheAccountStatus::Changed);
398        assert_eq!(
399            acct.info.as_ref().map(|i| i.balance),
400            Some(U256::from(3_000_010u64))
401        );
402    }
403
404    #[test]
405    fn repro_loaded_credit_then_recredit_across_merges() {
406        let mut state = make_state();
407        let mut overlay = StateOverlay::new();
408        let a = address!("00000000000000000000000000000000feed0003");
409        // Pre-existing on-disk account (Loaded).
410        state.cache.accounts.insert(
411            a,
412            CacheAccount {
413                account: Some(PlainAccount {
414                    info: AccountInfo {
415                        balance: U256::from(10u64),
416                        ..Default::default()
417                    },
418                    storage: Default::default(),
419                }),
420                status: CacheAccountStatus::Loaded,
421            },
422        );
423        credit(&mut state, &mut overlay, a, U256::from(1_000_000u64));
424        credit(&mut state, &mut overlay, a, U256::from(1_000_000u64));
425        let bal = state
426            .bundle_state
427            .state
428            .get(&a)
429            .and_then(|x| x.info.as_ref())
430            .map(|i| i.balance);
431        assert_eq!(bal, Some(U256::from(2_000_010u64)));
432    }
433
434    #[test]
435    fn created_then_emptied_in_same_tx_produces_no_bundle_entry() {
436        let mut state = make_state();
437        let mut overlay = StateOverlay::new();
438        let transient = address!("0000000000000000000000000000000000007a17");
439
440        overlay.record_pre_touch(&mut state, transient);
441        let entry = state.cache.accounts.get_mut(&transient).unwrap();
442        entry.account = Some(PlainAccount {
443            info: AccountInfo {
444                balance: U256::ZERO,
445                ..Default::default()
446            },
447            storage: Default::default(),
448        });
449
450        overlay.drain_and_apply(&mut state, &Default::default());
451        state.merge_transitions(BundleRetention::Reverts);
452
453        assert!(!state.bundle_state.state.contains_key(&transient));
454    }
455
456    #[test]
457    fn zombie_resurrection_reverts_to_absent() {
458        // A destructed account (cache account=None) carrying a stale status is
459        // resurrected present-empty via the zombie path. Forward it must be a
460        // present-empty leaf; on a block unwind it must revert to absent rather
461        // than be resurrected, whatever stale status the destruct left behind.
462        let addr = address!("00000000000000000000000000000000deadbeef");
463        for stale in [
464            CacheAccountStatus::InMemoryChange,
465            CacheAccountStatus::Loaded,
466            CacheAccountStatus::LoadedNotExisting,
467            CacheAccountStatus::LoadedEmptyEIP161,
468        ] {
469            let mut state = make_state();
470            let mut overlay = StateOverlay::new();
471            state.cache.accounts.insert(
472                addr,
473                CacheAccount {
474                    account: None,
475                    status: stale,
476                },
477            );
478            overlay.record_pre_touch(&mut state, addr);
479            let entry = state.cache.accounts.get_mut(&addr).unwrap();
480            entry.account = Some(PlainAccount {
481                info: AccountInfo::default(),
482                storage: Default::default(),
483            });
484            entry.status = CacheAccountStatus::InMemoryChange;
485
486            let zombies: rustc_hash::FxHashSet<Address> = std::iter::once(addr).collect();
487            overlay.drain_and_apply(&mut state, &zombies);
488            state.merge_transitions(BundleRetention::Reverts);
489
490            let forward = state
491                .bundle_state
492                .state
493                .get(&addr)
494                .and_then(|a| a.info.as_ref())
495                .cloned();
496            assert!(
497                forward.as_ref().is_some_and(|i| i.is_empty()),
498                "stale={stale:?}: zombie must persist present-empty forward, got {forward:?}"
499            );
500
501            state.bundle_state.revert(usize::MAX);
502            let reverted = state
503                .bundle_state
504                .state
505                .get(&addr)
506                .and_then(|a| a.info.as_ref())
507                .cloned();
508            assert!(
509                reverted.is_none(),
510                "stale={stale:?}: unwound zombie must be absent, got {reverted:?}"
511            );
512        }
513    }
514}