evm_fork_cache/access_set.rs
1//! Compact account/storage touch sets captured from EVM execution.
2//!
3//! This is intentionally smaller than an EIP-2930 transaction access list:
4//! it keeps accounts and `(account, slot)` pairs as sets so callers can merge
5//! simulation traces, estimate EIP-2929 warm-access savings, and prefetch cache
6//! entries without committing to a transaction encoding.
7
8use std::collections::HashSet;
9
10use alloy_eips::eip2930::{AccessList, AccessListItem};
11use alloy_primitives::{Address, B256, U256};
12use serde::{Deserialize, Serialize};
13
14/// Accounts and storage slots touched during EVM execution.
15///
16/// The shape is optimized for simulation bookkeeping: set union, overlap
17/// checks, warm-access gas estimation, and storage prefetching.
18#[derive(Clone, Debug, Default, Serialize, Deserialize)]
19pub struct StorageAccessList {
20 /// Contract addresses touched during execution.
21 pub accounts: HashSet<Address>,
22 /// `(contract, slot)` pairs read or written during execution.
23 pub slots: HashSet<(Address, U256)>,
24}
25
26impl StorageAccessList {
27 /// Returns true when no accounts or storage slots were captured.
28 pub fn is_empty(&self) -> bool {
29 self.accounts.is_empty() && self.slots.is_empty()
30 }
31
32 /// Number of distinct accounts touched by the execution.
33 pub fn account_count(&self) -> usize {
34 self.accounts.len()
35 }
36
37 /// Number of distinct storage slots touched by the execution.
38 pub fn slot_count(&self) -> usize {
39 self.slots.len()
40 }
41
42 /// Merge another touch set into this one (set union of accounts and slots).
43 ///
44 /// Duplicate accounts and `(account, slot)` pairs already present are not
45 /// counted twice, so [`StorageAccessList::account_count`] and
46 /// [`StorageAccessList::slot_count`] reflect distinct entries after merging.
47 ///
48 /// # Examples
49 ///
50 /// ```
51 /// use evm_fork_cache::StorageAccessList;
52 /// use alloy_primitives::{Address, U256};
53 ///
54 /// let acct_a = Address::repeat_byte(0x01);
55 /// let acct_b = Address::repeat_byte(0x02);
56 ///
57 /// let mut base = StorageAccessList::default();
58 /// base.accounts.insert(acct_a);
59 /// base.slots.insert((acct_a, U256::from(1)));
60 ///
61 /// let mut other = StorageAccessList::default();
62 /// other.accounts.insert(acct_a); // overlaps `base`, not double-counted
63 /// other.accounts.insert(acct_b);
64 /// other.slots.insert((acct_b, U256::from(2)));
65 ///
66 /// base.extend(&other);
67 ///
68 /// assert_eq!(base.account_count(), 2);
69 /// assert_eq!(base.slot_count(), 2);
70 /// assert!(!base.is_empty());
71 /// ```
72 pub fn extend(&mut self, other: &Self) {
73 self.accounts.extend(&other.accounts);
74 self.slots.extend(&other.slots);
75 }
76
77 /// Compute EIP-2929 gas saved when this touch set runs after `warm`.
78 ///
79 /// Cold account access costs 2600 gas versus 100 gas when warm, saving
80 /// 2500 gas. Cold SLOAD costs 2100 gas versus 100 gas when warm, saving
81 /// 2000 gas.
82 pub fn marginal_gas_savings(&self, warm: &Self) -> u64 {
83 let shared_accounts = self.accounts.intersection(&warm.accounts).count() as u64;
84 let shared_slots = self.slots.intersection(&warm.slots).count() as u64;
85 shared_accounts * 2500 + shared_slots * 2000
86 }
87
88 /// Convert this touch set into an EIP-2930 transaction access list.
89 pub fn to_eip2930(&self) -> AccessList {
90 let mut by_address: std::collections::BTreeMap<Address, Vec<B256>> = self
91 .accounts
92 .iter()
93 .copied()
94 .map(|addr| (addr, Vec::new()))
95 .collect();
96
97 for (address, slot) in &self.slots {
98 by_address
99 .entry(*address)
100 .or_default()
101 .push(B256::from(*slot));
102 }
103
104 AccessList(
105 by_address
106 .into_iter()
107 .map(|(address, mut storage_keys)| {
108 storage_keys.sort_unstable();
109 storage_keys.dedup();
110 AccessListItem {
111 address,
112 storage_keys,
113 }
114 })
115 .collect(),
116 )
117 }
118}
119
120impl From<&StorageAccessList> for AccessList {
121 fn from(value: &StorageAccessList) -> Self {
122 value.to_eip2930()
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn marginal_gas_savings_counts_only_overlap() {
132 let account_a = Address::repeat_byte(0x01);
133 let account_b = Address::repeat_byte(0x02);
134 let slot_1 = U256::from(1);
135 let slot_2 = U256::from(2);
136
137 let al = StorageAccessList {
138 accounts: [account_a, account_b].into_iter().collect(),
139 slots: [(account_a, slot_1), (account_b, slot_2)]
140 .into_iter()
141 .collect(),
142 };
143 let warm = StorageAccessList {
144 accounts: [account_a].into_iter().collect(),
145 slots: [(account_b, slot_2)].into_iter().collect(),
146 };
147
148 assert_eq!(al.marginal_gas_savings(&warm), 4500);
149 }
150
151 #[test]
152 fn eip2930_conversion_includes_address_only_entries() {
153 let account = Address::repeat_byte(0x01);
154 let storage_contract = Address::repeat_byte(0x02);
155 let mut al = StorageAccessList::default();
156 al.accounts.insert(account);
157 al.slots.insert((storage_contract, U256::from(4)));
158
159 let encoded = al.to_eip2930();
160
161 assert_eq!(encoded.0.len(), 2);
162 assert!(
163 encoded
164 .0
165 .iter()
166 .any(|item| item.address == account && item.storage_keys.is_empty())
167 );
168 assert!(encoded.0.iter().any(|item| item.address == storage_contract
169 && item.storage_keys == vec![B256::from(U256::from(4))]));
170 }
171}