Skip to main content

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}