1use alloy::{
10 eips::BlockId,
11 network::Ethereum,
12 primitives::{keccak256, map::B256HashMap, Address, Bytes, TxKind, B256, U256},
13 providers::{ext::DebugApi, Provider, RootProvider},
14 rpc::{
15 json_rpc::ErrorPayload,
16 types::{
17 state::{AccountOverride, StateOverride},
18 trace::geth::{GethDebugTracingCallOptions, GethDebugTracingOptions, PreStateConfig},
19 TransactionRequest,
20 },
21 },
22 sol,
23 sol_types::SolCall,
24};
25
26const MAX_BASE_SLOT: u16 = 640;
32const MAX_SLOTS_TO_VERIFY: usize = 48;
38pub(crate) const PROBE_SENTINEL: U256 = U256::from_limbs([0xdead_beef_cafe_babe, 0, 0, 0]);
40const OZ_V5_BALANCES_NS: B256 =
45 B256::new(alloy::hex!("52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00"));
46const OZ_V5_ALLOWANCES_NS: B256 =
50 B256::new(alloy::hex!("52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace01"));
51
52sol! {
53 interface IERC20LayoutProbe {
54 function balanceOf(address account) external view returns (uint256);
55 function allowance(address owner, address spender) external view returns (uint256);
56 }
57
58 interface ISharesToken {
61 function sharesOf(address account) external view returns (uint256);
62 }
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum KeyOrder {
68 Solidity,
70 Vyper,
72}
73
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum MappingPosition {
77 Direct {
79 base: u16,
81 key_order: KeyOrder,
83 },
84 OpenZeppelinV5,
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub struct TokenLayout {
92 storage_contract: Address,
93 balance: MappingPosition,
94 allowance: MappingPosition,
95}
96
97impl TokenLayout {
98 pub const fn new(
100 storage_contract: Address,
101 balance: MappingPosition,
102 allowance: MappingPosition,
103 ) -> Self {
104 Self { storage_contract, balance, allowance }
105 }
106
107 pub fn storage_contract(self) -> Address {
112 self.storage_contract
113 }
114
115 pub fn balance_slot(self, holder: Address) -> B256 {
117 balance_slot(holder, self.balance)
118 }
119
120 pub fn allowance_slot(self, owner: Address, spender: Address) -> B256 {
122 allowance_slot(owner, spender, self.allowance)
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
132pub enum DiscoveryError {
133 #[error("{0}")]
135 Unsupported(String),
136 #[error("{0}")]
138 Rpc(String),
139}
140
141pub async fn discover_layout(
143 provider: &RootProvider<Ethereum>,
144 token: Address,
145 holder: Address,
146 spender: Address,
147) -> Result<TokenLayout, DiscoveryError> {
148 let (storage_contract, balance) = discover_balance(provider, token, holder).await?;
149
150 let allowance_calldata =
151 IERC20LayoutProbe::allowanceCall { owner: holder, spender }.abi_encode();
152 let (allowance_contract, observed) =
153 find_accessed_slot(provider, token, &allowance_calldata).await?;
154 if allowance_contract != storage_contract {
155 return Err(DiscoveryError::Unsupported(format!(
156 "token {token:#x} stores balance and allowance in different contracts ({storage_contract:#x}, {allowance_contract:#x})"
157 )));
158 }
159 let allowance = recover_position(observed, |position| {
160 allowance_slot(holder, spender, position)
161 })
162 .ok_or_else(|| {
163 DiscoveryError::Unsupported(format!(
164 "could not recover a supported allowance mapping for {token:#x}; observed slot {observed:#x}"
165 ))
166 })?;
167
168 Ok(TokenLayout::new(storage_contract, balance, allowance))
169}
170
171async fn discover_balance(
178 provider: &RootProvider<Ethereum>,
179 token: Address,
180 holder: Address,
181) -> Result<(Address, MappingPosition), DiscoveryError> {
182 let probes = [
183 IERC20LayoutProbe::balanceOfCall { account: holder }.abi_encode(),
184 ISharesToken::sharesOfCall { account: holder }.abi_encode(),
185 ];
186 let mut failure = DiscoveryError::Unsupported(format!(
187 "could not identify a balance storage slot for {token:#x}"
188 ));
189 for calldata in probes {
190 match find_accessed_slot(provider, token, &calldata).await {
191 Ok((storage_contract, observed)) => {
192 if let Some(position) =
193 recover_position(observed, |position| balance_slot(holder, position))
194 {
195 return Ok((storage_contract, position));
196 }
197 failure = DiscoveryError::Unsupported(format!(
198 "could not recover a supported balance mapping for {token:#x}; observed slot {observed:#x}"
199 ));
200 }
201 Err(error @ DiscoveryError::Rpc(_)) => return Err(error),
204 Err(error) => failure = error,
205 }
206 }
207 Err(failure)
208}
209
210async fn find_accessed_slot(
212 provider: &RootProvider<Ethereum>,
213 token: Address,
214 calldata: &[u8],
215) -> Result<(Address, B256), DiscoveryError> {
216 let trace = provider
217 .debug_trace_call_prestate(
218 token_call(token, calldata),
219 BlockId::latest(),
220 GethDebugTracingCallOptions::new(GethDebugTracingOptions::prestate_tracer(
221 PreStateConfig::default(),
222 )),
223 )
224 .await
225 .map_err(|error| {
226 DiscoveryError::Rpc(format!(
227 "debug_traceCall prestate probe for {token:#x} failed: {error}"
228 ))
229 })?;
230
231 let mut candidates: Vec<(Address, B256)> = Vec::new();
235 for (&storage_contract, account) in trace.pre_state() {
236 candidates.extend(
237 account
238 .storage
239 .keys()
240 .rev()
241 .map(|&slot| (storage_contract, slot)),
242 );
243 }
244 candidates.truncate(MAX_SLOTS_TO_VERIFY);
245
246 let verdicts = futures::future::join_all(
249 candidates
250 .iter()
251 .map(|&(storage_contract, slot)| {
252 slot_matches(provider, token, storage_contract, calldata, slot)
253 }),
254 )
255 .await;
256 for (&(storage_contract, slot), verdict) in candidates.iter().zip(verdicts) {
257 if verdict? {
258 return Ok((storage_contract, slot));
259 }
260 }
261 Err(DiscoveryError::Unsupported(format!(
262 "could not identify a balance or allowance storage slot for {token:#x}"
263 )))
264}
265
266fn token_call(token: Address, calldata: &[u8]) -> TransactionRequest {
267 TransactionRequest {
268 to: Some(TxKind::Call(token)),
269 input: Bytes::copy_from_slice(calldata).into(),
270 ..Default::default()
271 }
272}
273
274async fn slot_matches(
276 provider: &RootProvider<Ethereum>,
277 token: Address,
278 storage_contract: Address,
279 calldata: &[u8],
280 slot: B256,
281) -> Result<bool, DiscoveryError> {
282 match provider
283 .call(token_call(token, calldata))
284 .overrides(state_override_single(storage_contract, slot, B256::from(PROBE_SENTINEL)))
285 .await
286 {
287 Ok(response) => {
288 Ok(response.len() >= 32 && U256::from_be_slice(&response[..32]) == PROBE_SENTINEL)
289 }
290 Err(error) => match error.as_error_resp() {
291 Some(payload) if is_revert(payload) => Ok(false),
294 Some(payload) => Err(DiscoveryError::Rpc(format!(
298 "sentinel probe for {token:#x} slot {slot:#x} was refused: {payload}"
299 ))),
300 None => Err(DiscoveryError::Rpc(format!(
301 "sentinel probe for {token:#x} slot {slot:#x} failed: {error}"
302 ))),
303 },
304 }
305}
306
307fn is_revert(payload: &ErrorPayload) -> bool {
309 payload.code == 3 || payload.message.contains("revert")
312}
313
314fn state_override_single(contract: Address, slot: B256, value: B256) -> StateOverride {
316 let mut state_diff = B256HashMap::default();
317 state_diff.insert(slot, value);
318 StateOverride::from_iter([(
319 contract,
320 AccountOverride { state_diff: Some(state_diff), ..Default::default() },
321 )])
322}
323
324fn recover_position(
328 slot: B256,
329 slot_for: impl Fn(MappingPosition) -> B256,
330) -> Option<MappingPosition> {
331 for base in 0..=MAX_BASE_SLOT {
332 for key_order in [KeyOrder::Solidity, KeyOrder::Vyper] {
333 let direct = MappingPosition::Direct { base, key_order };
334 if slot_for(direct) == slot {
335 return Some(direct);
336 }
337 }
338 }
339 (slot_for(MappingPosition::OpenZeppelinV5) == slot).then_some(MappingPosition::OpenZeppelinV5)
340}
341
342fn balance_slot(holder: Address, position: MappingPosition) -> B256 {
344 match position {
345 MappingPosition::Direct { base, key_order: KeyOrder::Solidity } => {
346 solidity_mapping(holder, B256::from(U256::from(base)))
347 }
348 MappingPosition::Direct { base, key_order: KeyOrder::Vyper } => vyper_mapping(holder, base),
349 MappingPosition::OpenZeppelinV5 => solidity_mapping(holder, OZ_V5_BALANCES_NS),
350 }
351}
352
353fn allowance_slot(owner: Address, spender: Address, position: MappingPosition) -> B256 {
355 match position {
356 MappingPosition::Direct { base, key_order: KeyOrder::Solidity } => {
357 solidity_mapping(spender, solidity_mapping(owner, B256::from(U256::from(base))))
358 }
359 MappingPosition::Direct { base, key_order: KeyOrder::Vyper } => {
360 let inner = vyper_mapping(owner, base);
361 let mut buffer = [0_u8; 64];
362 buffer[..32].copy_from_slice(inner.as_slice());
363 buffer[44..].copy_from_slice(spender.as_slice());
364 keccak256(buffer)
365 }
366 MappingPosition::OpenZeppelinV5 => {
367 solidity_mapping(spender, solidity_mapping(owner, OZ_V5_ALLOWANCES_NS))
368 }
369 }
370}
371
372fn solidity_mapping(holder: Address, base: B256) -> B256 {
373 let mut buffer = [0_u8; 64];
374 buffer[12..32].copy_from_slice(holder.as_slice());
375 buffer[32..].copy_from_slice(base.as_slice());
376 keccak256(buffer)
377}
378
379fn vyper_mapping(holder: Address, base: u16) -> B256 {
380 let mut buffer = [0_u8; 64];
381 buffer[30..32].copy_from_slice(&base.to_be_bytes());
382 buffer[44..].copy_from_slice(holder.as_slice());
383 keccak256(buffer)
384}
385
386#[cfg(test)]
387#[path = "../tests/simulation/token_layout.rs"]
388mod tests;