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}