use alloy::{
eips::BlockId,
network::Ethereum,
primitives::{keccak256, map::B256HashMap, Address, Bytes, TxKind, B256, U256},
providers::{ext::DebugApi, Provider, RootProvider},
rpc::{
json_rpc::ErrorPayload,
types::{
state::{AccountOverride, StateOverride},
trace::geth::{GethDebugTracingCallOptions, GethDebugTracingOptions, PreStateConfig},
TransactionRequest,
},
},
sol,
sol_types::SolCall,
};
const MAX_BASE_SLOT: u16 = 640;
const MAX_SLOTS_TO_VERIFY: usize = 48;
pub(crate) const PROBE_SENTINEL: U256 = U256::from_limbs([0xdead_beef_cafe_babe, 0, 0, 0]);
const OZ_V5_BALANCES_NS: B256 =
B256::new(alloy::hex!("52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00"));
const OZ_V5_ALLOWANCES_NS: B256 =
B256::new(alloy::hex!("52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace01"));
sol! {
interface IERC20LayoutProbe {
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
}
interface ISharesToken {
function sharesOf(address account) external view returns (uint256);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyOrder {
Solidity,
Vyper,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MappingPosition {
Direct {
base: u16,
key_order: KeyOrder,
},
OpenZeppelinV5,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TokenLayout {
storage_contract: Address,
balance: MappingPosition,
allowance: MappingPosition,
}
impl TokenLayout {
pub const fn new(
storage_contract: Address,
balance: MappingPosition,
allowance: MappingPosition,
) -> Self {
Self { storage_contract, balance, allowance }
}
pub fn storage_contract(self) -> Address {
self.storage_contract
}
pub fn balance_slot(self, holder: Address) -> B256 {
balance_slot(holder, self.balance)
}
pub fn allowance_slot(self, owner: Address, spender: Address) -> B256 {
allowance_slot(owner, spender, self.allowance)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DiscoveryError {
#[error("{0}")]
Unsupported(String),
#[error("{0}")]
Rpc(String),
}
pub async fn discover_layout(
provider: &RootProvider<Ethereum>,
token: Address,
holder: Address,
spender: Address,
) -> Result<TokenLayout, DiscoveryError> {
let (storage_contract, balance) = discover_balance(provider, token, holder).await?;
let allowance_calldata =
IERC20LayoutProbe::allowanceCall { owner: holder, spender }.abi_encode();
let (allowance_contract, observed) =
find_accessed_slot(provider, token, &allowance_calldata).await?;
if allowance_contract != storage_contract {
return Err(DiscoveryError::Unsupported(format!(
"token {token:#x} stores balance and allowance in different contracts ({storage_contract:#x}, {allowance_contract:#x})"
)));
}
let allowance = recover_position(observed, |position| {
allowance_slot(holder, spender, position)
})
.ok_or_else(|| {
DiscoveryError::Unsupported(format!(
"could not recover a supported allowance mapping for {token:#x}; observed slot {observed:#x}"
))
})?;
Ok(TokenLayout::new(storage_contract, balance, allowance))
}
async fn discover_balance(
provider: &RootProvider<Ethereum>,
token: Address,
holder: Address,
) -> Result<(Address, MappingPosition), DiscoveryError> {
let probes = [
IERC20LayoutProbe::balanceOfCall { account: holder }.abi_encode(),
ISharesToken::sharesOfCall { account: holder }.abi_encode(),
];
let mut failure = DiscoveryError::Unsupported(format!(
"could not identify a balance storage slot for {token:#x}"
));
for calldata in probes {
match find_accessed_slot(provider, token, &calldata).await {
Ok((storage_contract, observed)) => {
if let Some(position) =
recover_position(observed, |position| balance_slot(holder, position))
{
return Ok((storage_contract, position));
}
failure = DiscoveryError::Unsupported(format!(
"could not recover a supported balance mapping for {token:#x}; observed slot {observed:#x}"
));
}
Err(error @ DiscoveryError::Rpc(_)) => return Err(error),
Err(error) => failure = error,
}
}
Err(failure)
}
async fn find_accessed_slot(
provider: &RootProvider<Ethereum>,
token: Address,
calldata: &[u8],
) -> Result<(Address, B256), DiscoveryError> {
let trace = provider
.debug_trace_call_prestate(
token_call(token, calldata),
BlockId::latest(),
GethDebugTracingCallOptions::new(GethDebugTracingOptions::prestate_tracer(
PreStateConfig::default(),
)),
)
.await
.map_err(|error| {
DiscoveryError::Rpc(format!(
"debug_traceCall prestate probe for {token:#x} failed: {error}"
))
})?;
let mut candidates: Vec<(Address, B256)> = Vec::new();
for (&storage_contract, account) in trace.pre_state() {
candidates.extend(
account
.storage
.keys()
.rev()
.map(|&slot| (storage_contract, slot)),
);
}
candidates.truncate(MAX_SLOTS_TO_VERIFY);
let verdicts = futures::future::join_all(
candidates
.iter()
.map(|&(storage_contract, slot)| {
slot_matches(provider, token, storage_contract, calldata, slot)
}),
)
.await;
for (&(storage_contract, slot), verdict) in candidates.iter().zip(verdicts) {
if verdict? {
return Ok((storage_contract, slot));
}
}
Err(DiscoveryError::Unsupported(format!(
"could not identify a balance or allowance storage slot for {token:#x}"
)))
}
fn token_call(token: Address, calldata: &[u8]) -> TransactionRequest {
TransactionRequest {
to: Some(TxKind::Call(token)),
input: Bytes::copy_from_slice(calldata).into(),
..Default::default()
}
}
async fn slot_matches(
provider: &RootProvider<Ethereum>,
token: Address,
storage_contract: Address,
calldata: &[u8],
slot: B256,
) -> Result<bool, DiscoveryError> {
match provider
.call(token_call(token, calldata))
.overrides(state_override_single(storage_contract, slot, B256::from(PROBE_SENTINEL)))
.await
{
Ok(response) => {
Ok(response.len() >= 32 && U256::from_be_slice(&response[..32]) == PROBE_SENTINEL)
}
Err(error) => match error.as_error_resp() {
Some(payload) if is_revert(payload) => Ok(false),
Some(payload) => Err(DiscoveryError::Rpc(format!(
"sentinel probe for {token:#x} slot {slot:#x} was refused: {payload}"
))),
None => Err(DiscoveryError::Rpc(format!(
"sentinel probe for {token:#x} slot {slot:#x} failed: {error}"
))),
},
}
}
fn is_revert(payload: &ErrorPayload) -> bool {
payload.code == 3 || payload.message.contains("revert")
}
fn state_override_single(contract: Address, slot: B256, value: B256) -> StateOverride {
let mut state_diff = B256HashMap::default();
state_diff.insert(slot, value);
StateOverride::from_iter([(
contract,
AccountOverride { state_diff: Some(state_diff), ..Default::default() },
)])
}
fn recover_position(
slot: B256,
slot_for: impl Fn(MappingPosition) -> B256,
) -> Option<MappingPosition> {
for base in 0..=MAX_BASE_SLOT {
for key_order in [KeyOrder::Solidity, KeyOrder::Vyper] {
let direct = MappingPosition::Direct { base, key_order };
if slot_for(direct) == slot {
return Some(direct);
}
}
}
(slot_for(MappingPosition::OpenZeppelinV5) == slot).then_some(MappingPosition::OpenZeppelinV5)
}
fn balance_slot(holder: Address, position: MappingPosition) -> B256 {
match position {
MappingPosition::Direct { base, key_order: KeyOrder::Solidity } => {
solidity_mapping(holder, B256::from(U256::from(base)))
}
MappingPosition::Direct { base, key_order: KeyOrder::Vyper } => vyper_mapping(holder, base),
MappingPosition::OpenZeppelinV5 => solidity_mapping(holder, OZ_V5_BALANCES_NS),
}
}
fn allowance_slot(owner: Address, spender: Address, position: MappingPosition) -> B256 {
match position {
MappingPosition::Direct { base, key_order: KeyOrder::Solidity } => {
solidity_mapping(spender, solidity_mapping(owner, B256::from(U256::from(base))))
}
MappingPosition::Direct { base, key_order: KeyOrder::Vyper } => {
let inner = vyper_mapping(owner, base);
let mut buffer = [0_u8; 64];
buffer[..32].copy_from_slice(inner.as_slice());
buffer[44..].copy_from_slice(spender.as_slice());
keccak256(buffer)
}
MappingPosition::OpenZeppelinV5 => {
solidity_mapping(spender, solidity_mapping(owner, OZ_V5_ALLOWANCES_NS))
}
}
}
fn solidity_mapping(holder: Address, base: B256) -> B256 {
let mut buffer = [0_u8; 64];
buffer[12..32].copy_from_slice(holder.as_slice());
buffer[32..].copy_from_slice(base.as_slice());
keccak256(buffer)
}
fn vyper_mapping(holder: Address, base: u16) -> B256 {
let mut buffer = [0_u8; 64];
buffer[30..32].copy_from_slice(&base.to_be_bytes());
buffer[44..].copy_from_slice(holder.as_slice());
keccak256(buffer)
}
#[cfg(test)]
#[path = "../tests/simulation/token_layout.rs"]
mod tests;