evm_fork_cache/events/erc20.rs
1//! Generic ERC-20 `Transfer` decoder (generic core).
2//!
3//! [`Erc20TransferDecoder`] turns a standard ERC-20
4//! `Transfer(from, to, value)` log into two relative balance updates — a
5//! [`SlotDelta::Sub`] on the sender's balance slot and a
6//! [`SlotDelta::Add`] on the recipient's — so the cache
7//! tracks balances from the event stream without ever reading the resulting
8//! absolute balances. It is the log-driven form of the Phase 3 reactive-balance
9//! case.
10//!
11//! # Balance-slot derivation
12//!
13//! An ERC-20 `balanceOf` is a `mapping(address => uint256)` at some base slot.
14//! The decoder hashes the owner into that mapping the canonical Solidity way:
15//! `keccak256(abi.encode(owner, balance_slot))`. The base slot is configurable
16//! per token ([`with_token`](Erc20TransferDecoder::with_token)) with a default
17//! fallback ([`new`](Erc20TransferDecoder::new)), since different tokens place
18//! `balanceOf` at different slots.
19//!
20//! # Mint / burn legs
21//!
22//! A mint (`from == 0`) or burn (`to == 0`) has no real holder on the
23//! zero-address leg, so that leg is **skipped** — only the non-zero side emits a
24//! delta. Cold balances follow the Phase 3 contract: the
25//! [`SlotDelta`] is skipped at apply time and surfaced in
26//! [`StateDiff::skipped`](crate::StateDiff::skipped) (the caller seeds the
27//! balance, or the next read lazily fetches it). The decoder ignores the
28//! [`StateView`] — it is stateless.
29
30use std::collections::HashMap;
31
32use alloy_primitives::{Address, Log, U256, keccak256};
33use alloy_sol_types::SolValue;
34
35use crate::events::{EventDecoder, StateView};
36use crate::inspector::TransferInspector;
37use crate::mapping_probe::TrackedMapping;
38use crate::state_update::{SlotDelta, StateUpdate};
39
40/// Decodes ERC-20 `Transfer` logs into relative balance [`SlotDelta`] updates.
41///
42/// ```
43/// use alloy_primitives::{Address, Bytes, Log, U256, keccak256};
44/// use alloy_sol_types::SolValue;
45/// use evm_fork_cache::events::{EventDecoder, StateView};
46/// use evm_fork_cache::events::erc20::Erc20TransferDecoder;
47/// use evm_fork_cache::{SlotDelta, StateUpdate};
48///
49/// // A read-only view that reports every slot cold (decoder is stateless anyway).
50/// struct ColdView;
51/// impl StateView for ColdView {
52/// fn storage(&self, _: Address, _: U256) -> Option<U256> { None }
53/// }
54///
55/// let token = Address::repeat_byte(0x20);
56/// let from = Address::repeat_byte(0x21);
57/// let to = Address::repeat_byte(0x22);
58///
59/// // Transfer(from, to, 100) log: balanceOf mapping at slot 3.
60/// let sig = keccak256(b"Transfer(address,address,uint256)");
61/// let log = Log::new_unchecked(
62/// token,
63/// vec![sig, from.into_word(), to.into_word()],
64/// Bytes::copy_from_slice(&U256::from(100).to_be_bytes::<32>()),
65/// );
66///
67/// let decoder = Erc20TransferDecoder::new(U256::from(3));
68/// let updates = decoder.decode(&log, &ColdView);
69///
70/// let slot = |owner: Address| {
71/// U256::from_be_bytes(keccak256((owner, U256::from(3)).abi_encode()).0)
72/// };
73/// assert_eq!(updates, vec![
74/// StateUpdate::slot_delta(token, slot(from), SlotDelta::Sub(U256::from(100))),
75/// StateUpdate::slot_delta(token, slot(to), SlotDelta::Add(U256::from(100))),
76/// ]);
77/// ```
78pub struct Erc20TransferDecoder {
79 /// Balance mapping base slot per token (the `balanceOf` mapping's slot),
80 /// assuming Solidity `keccak(key‖slot)` layout.
81 balance_slots: HashMap<Address, U256>,
82 /// Layout-aware descriptors per token, taking precedence over
83 /// `balance_slots` — set via [`with_tracked`](Self::with_tracked) for Vyper
84 /// or Solady tokens whose byte order differs from Solidity's.
85 tracked: HashMap<Address, TrackedMapping>,
86 /// Fallback balance mapping base slot for tokens not in either map.
87 default_balance_slot: U256,
88}
89
90impl Erc20TransferDecoder {
91 /// Create a decoder with `default_balance_slot` as the `balanceOf` mapping
92 /// base slot for any token without a per-token override.
93 pub fn new(default_balance_slot: U256) -> Self {
94 Self {
95 balance_slots: HashMap::new(),
96 tracked: HashMap::new(),
97 default_balance_slot,
98 }
99 }
100
101 /// Override the `balanceOf` mapping base slot for `token`, assuming
102 /// Solidity's `keccak(key‖slot)` layout (builder style).
103 pub fn with_token(mut self, token: Address, balance_slot: U256) -> Self {
104 self.balance_slots.insert(token, balance_slot);
105 self
106 }
107
108 /// Configure a token from a discovered [`TrackedMapping`], honoring its
109 /// layout (Solidity / Vyper / Solady). Takes precedence over
110 /// [`with_token`](Self::with_token) for the same token.
111 ///
112 /// Pair with
113 /// [`EvmCache::discover_erc20_balance_slot`](crate::cache::EvmCache::discover_erc20_balance_slot):
114 /// discover once, then feed the layout here so event-driven balance tracking
115 /// writes the correct slot even for non-Solidity tokens.
116 pub fn with_tracked(mut self, tracked: TrackedMapping) -> Self {
117 self.tracked.insert(tracked.contract, tracked);
118 self
119 }
120
121 /// The hashed storage slot of `owner`'s balance for `token`, honoring a
122 /// tracked layout if present, else Solidity order at the configured base slot.
123 fn entry_slot(&self, token: Address, owner: Address) -> U256 {
124 if let Some(tracked) = self.tracked.get(&token)
125 && let Some(slot) = tracked.slot_for(owner.into_word())
126 {
127 return U256::from_be_bytes(slot.0);
128 }
129 balance_key(owner, self.balance_slot(token))
130 }
131
132 /// The configured Solidity balance mapping base slot for `token` (its
133 /// override, else the default).
134 fn balance_slot(&self, token: Address) -> U256 {
135 self.balance_slots
136 .get(&token)
137 .copied()
138 .unwrap_or(self.default_balance_slot)
139 }
140}
141
142/// The hashed storage slot of `balanceOf[owner]` for a `mapping(address =>
143/// uint256)` at `mapping_slot`.
144fn balance_key(owner: Address, mapping_slot: U256) -> U256 {
145 U256::from_be_bytes(keccak256((owner, mapping_slot).abi_encode()).0)
146}
147
148impl EventDecoder for Erc20TransferDecoder {
149 fn decode(&self, log: &Log, _view: &dyn StateView) -> Vec<StateUpdate> {
150 // Reuse the canonical ERC-20 Transfer signature match + topic/data decode.
151 // Returns None for a non-Transfer log (wrong topic0, <3 topics, <32 data
152 // bytes).
153 let Some(transfer) = TransferInspector::parse_transfer(log) else {
154 return Vec::new();
155 };
156
157 let mut updates = Vec::with_capacity(2);
158
159 // Skip the zero-address leg (mint = from == 0, burn = to == 0).
160 if transfer.from != Address::ZERO {
161 updates.push(StateUpdate::slot_delta(
162 transfer.token,
163 self.entry_slot(transfer.token, transfer.from),
164 SlotDelta::Sub(transfer.value),
165 ));
166 }
167 if transfer.to != Address::ZERO {
168 updates.push(StateUpdate::slot_delta(
169 transfer.token,
170 self.entry_slot(transfer.token, transfer.to),
171 SlotDelta::Add(transfer.value),
172 ));
173 }
174 updates
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::mapping_probe::{SlotLayout, TrackedMapping};
182 use alloy_primitives::{B256, Log, address};
183
184 /// The decoder ignores state, so a no-op `StateView` suffices.
185 struct NoState;
186 impl StateView for NoState {
187 fn storage(&self, _address: Address, _slot: U256) -> Option<U256> {
188 None
189 }
190 }
191
192 fn transfer_log(token: Address, from: Address, to: Address, value: U256) -> Log {
193 let sig = keccak256("Transfer(address,address,uint256)");
194 Log::new(
195 token,
196 vec![sig, from.into_word(), to.into_word()],
197 value.to_be_bytes_vec().into(),
198 )
199 .unwrap()
200 }
201
202 const TOKEN: Address = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
203 const HOLDER: Address = address!("00000000000000000000000000000000000000A1");
204
205 /// A mint (`from == 0`) must emit ONLY the recipient leg — never a write to
206 /// the zero address's balance slot.
207 #[test]
208 fn mint_skips_zero_address_from_leg() {
209 let decoder = Erc20TransferDecoder::new(U256::from(3u64));
210 let log = transfer_log(TOKEN, Address::ZERO, HOLDER, U256::from(100u64));
211 let updates = decoder.decode(&log, &NoState);
212 assert_eq!(
213 updates,
214 vec![StateUpdate::slot_delta(
215 TOKEN,
216 balance_key(HOLDER, U256::from(3u64)),
217 SlotDelta::Add(U256::from(100u64)),
218 )],
219 "mint emits only the recipient leg; no zero-address write"
220 );
221 }
222
223 /// A burn (`to == 0`) must emit ONLY the sender leg.
224 #[test]
225 fn burn_skips_zero_address_to_leg() {
226 let decoder = Erc20TransferDecoder::new(U256::from(3u64));
227 let log = transfer_log(TOKEN, HOLDER, Address::ZERO, U256::from(40u64));
228 let updates = decoder.decode(&log, &NoState);
229 assert_eq!(
230 updates,
231 vec![StateUpdate::slot_delta(
232 TOKEN,
233 balance_key(HOLDER, U256::from(3u64)),
234 SlotDelta::Sub(U256::from(40u64)),
235 )],
236 "burn emits only the sender leg; no zero-address write"
237 );
238 }
239
240 /// A zero-value self-transfer to/from the zero address (degenerate) emits
241 /// nothing — belt-and-suspenders that neither leg touches slot 0's holder.
242 #[test]
243 fn zero_to_zero_emits_nothing() {
244 let decoder = Erc20TransferDecoder::new(U256::from(3u64));
245 let log = transfer_log(TOKEN, Address::ZERO, Address::ZERO, U256::from(5u64));
246 assert!(decoder.decode(&log, &NoState).is_empty());
247 }
248
249 /// With a discovered non-Solidity layout, the emitted slot uses that byte
250 /// order — and the zero leg is still skipped.
251 #[test]
252 fn layout_aware_uses_tracked_order_and_still_skips_zero() {
253 let tracked = TrackedMapping::new(TOKEN, U256::from(2u64), SlotLayout::VyperMapping);
254 let decoder = Erc20TransferDecoder::new(U256::from(3u64)).with_tracked(tracked);
255 let log = transfer_log(TOKEN, Address::ZERO, HOLDER, U256::from(7u64));
256 let updates = decoder.decode(&log, &NoState);
257
258 // Vyper order: keccak(slot ‖ key), NOT the Solidity default slot 3.
259 let expected_slot = {
260 let mut pre = [0u8; 64];
261 pre[31] = 2;
262 pre[32..64].copy_from_slice(HOLDER.into_word().as_slice());
263 B256::from(keccak256(pre))
264 };
265 assert_eq!(
266 updates,
267 vec![StateUpdate::slot_delta(
268 TOKEN,
269 U256::from_be_bytes(expected_slot.0),
270 SlotDelta::Add(U256::from(7u64)),
271 )]
272 );
273 }
274}