Skip to content

Conversation

@kaze-cow
Copy link
Contributor

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

Description

Implement a more reliable, and likely (usually) faster approach for detecting solidity balance override storage addresses.

Rather than bulk storage override/scanning many storage slots for a match, a single debug_traceCall is made on the ERC20 balanceOf function, and reading the resolved SLOAD slots. If only one SLOAD is detected, then we are done. If more than one slot is found, each slot can be tested through eth_call with a single slot override. We test from last accessed to first accessed, as the last accessed storage slot is theoretically the most likely to be the balance slot (as that would be the return value). Since storage slots could be ccessed from anywhere (ex. not just in a solidity mapping), a new DirectSlot strategy has been added.

Probably the only major drawback of the DirectSlot strategy is that it can only be used for a single account. If a separate account balance from the check needs to be overridden, either the slot has to be recalculated, or the balance has to be sent from a separate address after allocation (ex. Spardose). Due to this limitation, any detected slots are additionally tested to match up with SolidityMapping, and the SolidityMapping strategy is returned instead of DirectSlot if able.

This approach is very similar to one followed by Foundry for their deal cheatcode. This is replacing the existing pattern of overriding 50 storage slots and seeing which test balance gets applied.

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.
  • Works even if the token calls a separate contract to get the balance (rather than reading from its internal storage)
  • 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 potentially. Performance allowing.

Potential drawbacks:

  • debug_traceCall isnt supported everywhere. This could be overcome by using eth_createAccessList as a fallback, but the createAccessList API does not return accessed storage slots in order, so the check could be considerably more expensive if the balance function accesses many slots.
  • The DirectSlot strategy can only update the balance of a single token holder whereas SolidityMapping can update any holder.

Other considerations:

  • Since we now support just about every reasonable token out there with this system, we should consider automatically banning any tokens for which we cannot override the balance. this will make simulations much more simple and reliable.

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 🙏.

When possible this storage detection method will use SolidityMapping to assist in finding cases where it is possible to use. So there should be no need to do heuristic scanning anymore, so I have removed this code segment (and any supporting code).

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
/// 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

@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
@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
…rides/detector.rs

Co-authored-by: Jan [Yann] <4518474+fafk@users.noreply.github.com>
import "./NonStandardERC20Balances.sol";

contract RemoteERC20Balances is NonStandardERC20Balances {
bool internal immutable balanceFromHere;
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit

Suggested change
bool internal immutable balanceFromHere;
bool internal immutable isBalanceFromHere;

And possible what does it mean to be "here", the users[user].balance seems to show up from nowhere and it's kind of confusing

Copy link
Contributor Author

@kaze-cow kaze-cow Dec 18, 2025

Choose a reason for hiding this comment

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

the users[user].balance seems to show up from nowhere and it's kind of confusing

yea, sorry about that. as you probably saw it inherits from the original ERC20 contract. The alternative was code duplication, or making a base contract that we dont already have it appears

Copy link
Contributor Author

Choose a reason for hiding this comment

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

renamed to isLocalBalance for now. I'm guessing ew are ok on the users[user].balance showing up out of nowhere?

@kaze-cow kaze-cow force-pushed the feat/tracing-balance-override branch from 53c8378 to 0a0408b Compare December 18, 2025 05:44
Copy link
Contributor

@squadgazzz squadgazzz left a comment

Choose a reason for hiding this comment

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

Great job 🚀
Left a few nit comments.

Apologies for the delayed reviewing process and leaving so many comments.

github-merge-queue bot pushed a commit that referenced this pull request Dec 18, 2025
…rces (#3991)

# Description
<!--- Describe your changes to provide context for reviewers, including
why it is needed -->

On my other PR #3937 , I was
having difficulty generating the solidity JSONs, and when I did generate
them, the format was not correct. I realized that part of the problem is
that the CI should be natively checking for this, but it currently does
not, so I added a job.

Additionally, my computer is ARM64, but the ethereum solidity image is
only compatible with `x86_64`, so I had to add a flag to fix this.

Finally, the format followed by the other files in the artifacts JSON
directory uses a pretty-printed JSON format, but the existing Makefile
uses compact format with `jq -c`. So I removed the `-c` flag to ensure
this consistent format.

It turns out taht, likely due to a newer solidity version, many of the
artifacts have changed slightly since they were originally built. So
that is included as part of this PR.

One other issue that can occur is if the artifact file already exists on
your computer, it may fail to write the new version since `make` only
builds files that don't exist (or something like that). To resolve this,
the `-B` option is used to force rebuilding of all targets.

# Changes
<!-- List of detailed changes (how the change is accomplished) -->

- [x] modify the Makefile as described above
- [x] add CI job
- [x] Ensure the CI job can run properly on ARM64
- [x] run `cd crates/contracts/solidity && make -B artifacts`

## How to test

Check the new CI job `solidity-artifacts`

Run `make -B artifacts` from `crates/contracts/solidity` to see how it
behaves on your own machine.

<!--
## Related Issues

Fixes #
-->
@kaze-cow kaze-cow enabled auto-merge December 18, 2025 15:54
@kaze-cow
Copy link
Contributor Author

Apologies for the delayed reviewing process and leaving so many comments.

No problem at all! This PR rightly deserves scrutiny considering the breadth of impact it can have. Reviews have been great all around 👍

@kaze-cow kaze-cow added this pull request to the merge queue Dec 18, 2025
Merged via the queue into main with commit 3480ee7 Dec 18, 2025
19 checks passed
@kaze-cow kaze-cow deleted the feat/tracing-balance-override branch December 18, 2025 16:18
@github-actions github-actions bot locked and limited conversation to collaborators Dec 18, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants