Skip to main content

fynd_core/simulation/
token_layout.rs

1//! Trace-guided ERC-20 storage-layout discovery.
2//!
3//! State overrides only help when they land on the slots a token actually reads. Most ERC-20s
4//! use Solidity's `keccak256(holder || base_slot)` mapping convention, but real tokens also use
5//! Vyper's reversed order, deep inheritance slots, proxies whose storage lives elsewhere, and
6//! rebasing shares. This module traces the token's read-only access, validates the observed slot
7//! with a sentinel override, then recovers the mapping convention needed to fund a simulated swap.
8
9use 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
26/// Highest mapping base searched when recovering a slot's convention.
27///
28/// Recovery is local keccak arithmetic, not RPC, so the bound only caps CPU: 640 bases across two
29/// key orders is a few thousand hashes. It sits well past the deepest base a token in the Tycho
30/// set uses, and a token beyond it fails discovery rather than being funded wrongly.
31const MAX_BASE_SLOT: u16 = 640;
32/// Slots sentinel-verified per probe, across every account the trace touched.
33///
34/// Each one costs an `eth_call`, and they all run against the layout-discovery timeout. The bound
35/// covers the whole probe rather than one account, so a proxy whose read spans several accounts
36/// costs no more than a token that keeps everything in one.
37const MAX_SLOTS_TO_VERIFY: usize = 48;
38/// A value that survives common packed-balance flags and narrow integer casts.
39pub(crate) const PROBE_SENTINEL: U256 = U256::from_limbs([0xdead_beef_cafe_babe, 0, 0, 0]);
40/// OpenZeppelin v5's ERC-20 balances mapping, under the namespace ERC-7201 prescribes.
41///
42/// This is `keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ERC20")) - 1)) &
43/// ~bytes32(uint256(0xff))`.
44const OZ_V5_BALANCES_NS: B256 =
45    B256::new(alloy::hex!("52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00"));
46/// OpenZeppelin v5's ERC-20 allowances mapping.
47///
48/// Allowances are field 1 of `ERC20Storage`, so their namespace is the balances namespace plus one.
49const 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    /// The view a share-accounted rebasing token keeps its mapping under. stETH is the one in the
59    /// Tycho set; the rest of the family answers the same call.
60    interface ISharesToken {
61        function sharesOf(address account) external view returns (uint256);
62    }
63}
64
65/// Mapping-key convention used by a token implementation.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum KeyOrder {
68    /// Solidity: `keccak256(pad32(address) || pad32(slot))`.
69    Solidity,
70    /// Vyper: `keccak256(pad32(slot) || pad32(address))`.
71    Vyper,
72}
73
74/// The base of one balance or allowance mapping.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum MappingPosition {
77    /// A small integer base slot under Solidity or Vyper mapping layout.
78    Direct {
79        /// Declaration order of the mapping in the contract's storage.
80        base: u16,
81        /// Which way the implementation hashes the key and the base.
82        key_order: KeyOrder,
83    },
84    /// OpenZeppelin v5's namespaced storage. Which namespace applies follows from the mapping
85    /// being addressed, so a balance reads the balances one and an allowance the allowances one.
86    OpenZeppelinV5,
87}
88
89/// The slots needed to fund and approve one simulated token input.
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub struct TokenLayout {
92    storage_contract: Address,
93    balance: MappingPosition,
94    allowance: MappingPosition,
95}
96
97impl TokenLayout {
98    /// Creates a layout from known positions.
99    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    /// Contract whose state holds this token's balances and allowances.
108    ///
109    /// A proxy keeps them somewhere other than the address the swap calls, so an override goes to
110    /// this contract rather than to the token.
111    pub fn storage_contract(self) -> Address {
112        self.storage_contract
113    }
114
115    /// Slot holding one holder's balance, or its share balance on a rebasing token.
116    pub fn balance_slot(self, holder: Address) -> B256 {
117        balance_slot(holder, self.balance)
118    }
119
120    /// Slot holding what one owner has approved one spender to spend.
121    pub fn allowance_slot(self, owner: Address, spender: Address) -> B256 {
122        allowance_slot(owner, spender, self.allowance)
123    }
124}
125
126/// Why a token's storage layout could not be resolved.
127///
128/// The two are cached differently: a layout this module cannot resolve is a property of the token
129/// and stays decided, while a node that failed to answer says nothing about the token and is
130/// retried on the next quote.
131#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
132pub enum DiscoveryError {
133    /// The token's layout is not one this module recovers.
134    #[error("{0}")]
135    Unsupported(String),
136    /// The node did not answer a probe.
137    #[error("{0}")]
138    Rpc(String),
139}
140
141/// Resolves the storage a quote's input token reads, so an override can fund it.
142pub 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
171/// Places the balance mapping, trying the plain balance view before the share-accounted one.
172///
173/// A rebasing token multiplies shares by a pooled rate inside `balanceOf`, so tracing that call
174/// finds the arithmetic and not the mapping; `sharesOf` reads the mapping directly. The retry
175/// replaces a list of addresses, which would name only the tokens already known to need it and
176/// would have to be kept per chain.
177async 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            // A node that refused to answer says nothing about the token, so it ends discovery
202            // rather than sending the caller on to a view this token may not even have.
203            Err(error @ DiscoveryError::Rpc(_)) => return Err(error),
204            Err(error) => failure = error,
205        }
206    }
207    Err(failure)
208}
209
210/// Finds the slot a read-only call depends on, by overwriting each slot it touched in turn.
211async 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    // Highest keys first: a mapping slot is a keccak hash and lands near the top of the key order,
232    // while a contract's fixed fields sit at 0, 1, 2 and sort to the bottom. Taking the cap from
233    // that end reaches the mapping on a token that reads many fixed slots.
234    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    // Every candidate is verified with its own `eth_call`, so they go out together: run in turn
247    // they would spend the discovery timeout on round trips rather than on work.
248    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
274/// Whether overwriting one slot changes what the token reports, which is what identifies it.
275async 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            // A guarded proxy reverts when its implementation slot is overwritten, which proves
292            // the slot is not the mapping.
293            Some(payload) if is_revert(payload) => Ok(false),
294            // Every other error response -- a rate limit, a compute budget, a head that moved --
295            // proves nothing about the slot. Counting it as a miss would end discovery in
296            // "could not identify", and that verdict is cached for the life of the process.
297            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
307/// Whether an error response is the contract reverting rather than the node declining to run.
308fn is_revert(payload: &ErrorPayload) -> bool {
309    // 3 is the code geth returns for a reverted call; the message covers nodes that report the
310    // same thing under a code of their own.
311    payload.code == 3 || payload.message.contains("revert")
312}
313
314/// Builds an override that writes one storage value for a contract.
315fn 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
324/// Finds the convention whose arithmetic reproduces an observed slot.
325///
326/// `slot_for` closes over the keys, so one search serves balances and allowances alike.
327fn 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
342/// Slot holding one holder's balance under a given convention.
343fn 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
353/// Slot holding one owner-and-spender allowance under a given convention.
354fn 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;