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::state_update::{SlotDelta, StateUpdate};
38
39/// Decodes ERC-20 `Transfer` logs into relative balance [`SlotDelta`] updates.
40///
41/// ```
42/// use alloy_primitives::{Address, Bytes, Log, U256, keccak256};
43/// use alloy_sol_types::SolValue;
44/// use evm_fork_cache::events::{EventDecoder, StateView};
45/// use evm_fork_cache::events::erc20::Erc20TransferDecoder;
46/// use evm_fork_cache::{SlotDelta, StateUpdate};
47///
48/// // A read-only view that reports every slot cold (decoder is stateless anyway).
49/// struct ColdView;
50/// impl StateView for ColdView {
51///     fn storage(&self, _: Address, _: U256) -> Option<U256> { None }
52/// }
53///
54/// let token = Address::repeat_byte(0x20);
55/// let from = Address::repeat_byte(0x21);
56/// let to = Address::repeat_byte(0x22);
57///
58/// // Transfer(from, to, 100) log: balanceOf mapping at slot 3.
59/// let sig = keccak256(b"Transfer(address,address,uint256)");
60/// let log = Log::new_unchecked(
61///     token,
62///     vec![sig, from.into_word(), to.into_word()],
63///     Bytes::copy_from_slice(&U256::from(100).to_be_bytes::<32>()),
64/// );
65///
66/// let decoder = Erc20TransferDecoder::new(U256::from(3));
67/// let updates = decoder.decode(&log, &ColdView);
68///
69/// let slot = |owner: Address| {
70///     U256::from_be_bytes(keccak256((owner, U256::from(3)).abi_encode()).0)
71/// };
72/// assert_eq!(updates, vec![
73///     StateUpdate::slot_delta(token, slot(from), SlotDelta::Sub(U256::from(100))),
74///     StateUpdate::slot_delta(token, slot(to), SlotDelta::Add(U256::from(100))),
75/// ]);
76/// ```
77pub struct Erc20TransferDecoder {
78    /// Balance mapping base slot per token (the `balanceOf` mapping's slot).
79    balance_slots: HashMap<Address, U256>,
80    /// Fallback balance mapping base slot for tokens not in the map.
81    default_balance_slot: U256,
82}
83
84impl Erc20TransferDecoder {
85    /// Create a decoder with `default_balance_slot` as the `balanceOf` mapping
86    /// base slot for any token without a per-token override.
87    pub fn new(default_balance_slot: U256) -> Self {
88        Self {
89            balance_slots: HashMap::new(),
90            default_balance_slot,
91        }
92    }
93
94    /// Override the `balanceOf` mapping base slot for `token` (builder style).
95    pub fn with_token(mut self, token: Address, balance_slot: U256) -> Self {
96        self.balance_slots.insert(token, balance_slot);
97        self
98    }
99
100    /// The configured balance mapping base slot for `token` (its override, else
101    /// the default).
102    fn balance_slot(&self, token: Address) -> U256 {
103        self.balance_slots
104            .get(&token)
105            .copied()
106            .unwrap_or(self.default_balance_slot)
107    }
108}
109
110/// The hashed storage slot of `balanceOf[owner]` for a `mapping(address =>
111/// uint256)` at `mapping_slot`.
112fn balance_key(owner: Address, mapping_slot: U256) -> U256 {
113    U256::from_be_bytes(keccak256((owner, mapping_slot).abi_encode()).0)
114}
115
116impl EventDecoder for Erc20TransferDecoder {
117    fn decode(&self, log: &Log, _view: &dyn StateView) -> Vec<StateUpdate> {
118        // Reuse the canonical ERC-20 Transfer signature match + topic/data decode.
119        // Returns None for a non-Transfer log (wrong topic0, <3 topics, <32 data
120        // bytes).
121        let Some(transfer) = TransferInspector::parse_transfer(log) else {
122            return Vec::new();
123        };
124
125        let slot = self.balance_slot(transfer.token);
126        let mut updates = Vec::with_capacity(2);
127
128        // Skip the zero-address leg (mint = from == 0, burn = to == 0).
129        if transfer.from != Address::ZERO {
130            updates.push(StateUpdate::slot_delta(
131                transfer.token,
132                balance_key(transfer.from, slot),
133                SlotDelta::Sub(transfer.value),
134            ));
135        }
136        if transfer.to != Address::ZERO {
137            updates.push(StateUpdate::slot_delta(
138                transfer.token,
139                balance_key(transfer.to, slot),
140                SlotDelta::Add(transfer.value),
141            ));
142        }
143        updates
144    }
145}