Skip to content

Conversation

@kaze-cow
Copy link
Contributor

@kaze-cow kaze-cow commented Dec 1, 2025

Description

Implement a faster and more reliable approach for detecting solidity balance override storage addresses.

Rather than bulk storage override/scanning many storage slots for a match, a new detector strategy DirectSlot is added to detect the storage slot through a single debug_traceCall on the ERC20 balanceOf function, and reading the resolved SLOAD slots. This approach is very similar to one followed by Foundry for their deal cheatcode.

This method has advantages:

  • Assuming that there is only one SLOAD in a balanceOf call (most cases), only a single debug_traceCall is required for a single token/address pair. In the case of more than one SLOAD, an additional call is required for each slot to validate the correct storage, which is still inexpensive.
  • Basically any token that stores a user's balance in a single uint256 will be supported by this method.
  • Since this strategy can be reliably used anywhere, we can probably deprecate/remove the other strategies

Since slot detection using this method is only able to find the storage slot for a specific account in question, the interface needed to be updated in a couple places to reflect this. This also means that there is a potential performance disadvantage with this method since balance overrides for different addresses cannot be detected due to not computing via Solidity mapping. This could be mitigated by only supporting balance overrides on the spardose contract (followed by a transfer call) to the needed account as required.

This method requires a node that supports the debug_traceCall API. I believe this is the case for our infra, but please double check 🙏.

For now I left the original SolidityMapping detector strategy as a backup in case DirectSlot fails. However, there should theoretically be no cases where SolidityMapping would detect when DirectSlot does not, so it would be worth removing and relying solely on DirectSlot.

I added a E2E test and contract to validate that the storage slot detection is working as expected.

Changes

  • add DirectSlot detector
  • update caching interface to cache by the pair (token address, overridden balance address)
  • add E2E test to verify the detector works in practice

How to test

Run just test-e2e local_node_trace_based_balance_detection

Implement a better approach for detecting.

Rather than scanning solidity storage slots for a mapping, a new detector strategy `DirectSlot` is added to detect the storage slot through a single `debug_traceCall` on the ERC20 `balanceOf` function, and reading the resolved `SLOAD` slots.
This approach is very similar to [one followed by Foundry for their `deal` cheatcode](https://github.com/foundry-rs/foundry/blob/9b13b811849e73654fae046986b8730df8a0d64d/crates/anvil/src/eth/api.rs#L2259).

This method has a few advantages:
* Assuming that there is only one `SLOAD` in a `balanceOf` call (most cases), only a single `debug_traceCall` is required for a single token/address pair. In the case of more than one SLOAD, an additional call is required for each slot to validate the correct storage, which is still inexpensive.
* Basically any token that stores a user's balance in a single `uint256` will be supported by this method.

Since slot detection using this method is only able to find the storage slot for a specific account in question, the interface needed to be updated in a couple places to reflect this. This also means that there is a potential performance disadvantage with this method since balance overrides for different addresses cannot be detected due to not computing via Solidity mapping. This could be mitigated by only supporting balance overrides on the spardose contract (followed by a `transfer` call) to the needed account as required.

This method requires a node that supports the `debug_traceCall` API. I believe this is the case for our infra, but please double check 🙏.

For now I left the original `SolidityMapping` detector strategy as a backup in case `DirectSlot` fails. However, there should theoretically be no cases where `SolidityMapping` would detect when `DirectSlot` does not, so it would be worth removing and relying solely on `DirectSlot`.

I added a E2E test and contract to validate that the storage slot detection is working as expected.
@kaze-cow kaze-cow requested a review from a team as a code owner December 1, 2025 04:06
@kaze-cow kaze-cow requested a review from MartinquaXD December 1, 2025 04:06

/// Custom deserializer for stack values that handles variable-length hex
/// strings (side note: I don't know why this has to be so complicated...)
fn deserialize_hex_stack<'de, D>(deserializer: D) -> Result<Vec<primitive_types::H256>, D::Error>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a better way to do this? ran into some issues with doing somethat should be extremely basic (parsing hex string to a B256)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this does not answer your specific question I think this would go away if we use eth_createAccessList instead of debug_traceCall to figure out the relevant storage slots. That should be a lot simpler and not rely on non-standard modules to be enabled in the node client.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤦‍♂️ had no idea this API existed. Apparently AI didn't know about it either 😆 Looks super standardized too.

Copy link
Contributor Author

@kaze-cow kaze-cow Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok looking at this again, I actually think the current API call is still worth considering. In general access lists give us what we need, but there is not requirement that the accessed storage slots be returned in a particular order. So, for example on etherscan https://etherscan.io/tx/0x46ef9c9771286847cb95dc4003b9f6ffae2e0d8630b6edcd19a7872875758484#accesslist , they are returned in alphabetical order, and now the scanning can be very expensive.

with trace we can tell which SLOAD(s) were last, and in a balanceOf call, it seems reasonable to expect the last SLOADs will be the slot we are looking for, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still somewhat worried about debug_ calls being less supported by external node providers. Let's go with trying debug_traceCall first and if that fails fall back to eth_createAccessList.
Also you actually don't have to implement debug_traceCall yourself since alloy provides that out of the box: https://docs.rs/alloy/latest/alloy/providers/ext/trait.DebugApi.html#tymethod.debug_trace_call.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder on this comment since the PR currently re-implements a bunch of stuff that alloy has out of the box (unless there is a reason for this which I don't see).

/// debug_traceCall. This is similar to Foundry's `deal` approach where
/// we trace a balanceOf call to find which storage slot is accessed for
/// a given account.
DirectSlot { slot: H256 },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs to also store a target_contract. IIRC tokens like EURe on gnosis chain store the balances in a completely different contract.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea i probably should have mentioned this. for right now in order to keep the implementation simpler I decided not to update storage slots in other contracts since its somewhat rare and I thought it would amke things a lot more complicated with the trace early on. But its not like it would be hard to add looking at it now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so I added target contract as you suggest, but in order to properly encode the overrides object in the case that its DirectSlot, both SolidityMapping and SoladyMapping also need to have the target_contract as well. This turned out to be a somewhat big refactor after all. LMK if we should keep it or revert. a5f70bc

"detected balance slot via trace (single SLOAD) for token {:?}: slot {:?}",
token, slot
);
return Ok(Strategy::DirectSlot { slot });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO in a follow up PR we should get rid of the old brute force approach and use the same idea here.
That way we'll only have 1 strategy that is able to detect the balances storage slot for superior caching or just return the specific slot if brute forcing a couple of storage slots doesn't work.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i agree

@kaze-cow kaze-cow requested a review from MartinquaXD December 3, 2025 06:16
@kaze-cow kaze-cow requested a review from a team December 4, 2025 09:52
// changing unnecessary storage slots could negatively affect the execution (ex.
// overriding an upgradable proxy contract target)
for (i, slot) in storage_slots.iter().rev().enumerate() {
if let Ok(strategy) = self.verify_slot_is_balance(token, holder, *slot).await {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To properly marry the "brute force" approach with this one this is where we should start brute forcing.
So if we find the n where hash(user_address || n) == balance_slot we know where the balance mapping lives.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great idea! I suppose that gives us a major reason to continue using the existing solidity mapping method

biggest fear I have is you could still end up in a situation where you do the mapping filter and still end up with matching slots that just so happen to be related to something separate from the actual balance mapping (ex. if balanceOf checks an epoch before getting the actual balance, which is not actually stored in a solidity mapping. something convoluted.). ofc these are extreme cases. but yea, the performance benefit is high, worthwhile change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fyi I implemetned this in such a way that it doesn't reuse the code in SolidityMapping. I did this to hopefully make a possible later refactor of this code a bit easier.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While using the brute force idea to pick more likely storage slots might be a neat optimization to save a few eth_calls the real benefit only comes from also being able to return the SolidityMapping strategy from detect_with_trace(). Only if we store the SolidityMapping strategy can we avoid all future eth_calls to compute the correct storage slot for new owner addresses.

So what I'm suggesting is:

  1. use debug_traceCall / eth_createAccessList to figure out the relevant storage slots
  2. optionally use the brute forcing stuff to find more likely candidates
  3. find the EXACT storage slot for the given owner
  4. use the brute force logic to see if we can find the storage slot that holds the balances mapping
    if that works return SolidityMapping with the found mapping slot
    else return DirectSlot

Step 4 should also check the Solady mapping but IIRC that only requires 1 check.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it, yea that shouldn't be too hard. my brain is pretty much on the track now that these strategies were going out the door so I was going all in on the DirectSlot stuff + only using spardose (rather than filling any address, which delivers inconsistent performance)

@kaze-cow kaze-cow requested a review from MartinquaXD December 9, 2025 08:09
@kaze-cow kaze-cow enabled auto-merge December 9, 2025 08:09
@kaze-cow kaze-cow disabled auto-merge December 9, 2025 11:00
Comment on lines +96 to +100
storage_slots.sort_by(|a, b| {
heuristic_slots
.contains(&b.1)
.cmp(&heuristic_slots.contains(&a.1))
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be replaced with sort_by_key() for less ambiguity if the comparison was implemented correctly or even sort_by_cached_key() if frequent hashmap look ups are actually too slow.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so much easier! done.


/// Custom deserializer for stack values that handles variable-length hex
/// strings (side note: I don't know why this has to be so complicated...)
fn deserialize_hex_stack<'de, D>(deserializer: D) -> Result<Vec<primitive_types::H256>, D::Error>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder on this comment since the PR currently re-implements a bunch of stuff that alloy has out of the box (unless there is a reason for this which I don't see).

let mut map_slot = U256::from(start_slot);
for _ in 0..self.probing_depth {
strategies.push(Strategy::SolidityMapping {
target_contract: H160::default(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a bit out of my element here, but the default strategies above pass target_contract and this one defaults to zero. Is that right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea... something about this seems wrong. probably DirectSlot is taking over and so SolidityMapping strategy issues are no longer showing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants