-
Notifications
You must be signed in to change notification settings - Fork 148
feat(balance_overrides): use debug_traceCall to find slots #3937
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
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.
|
|
||
| /// 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> |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
crates/shared/src/price_estimation/trade_verifier/balance_overrides/detector.rs
Show resolved
Hide resolved
| /// 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 }, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
crates/shared/src/price_estimation/trade_verifier/balance_overrides/detector.rs
Outdated
Show resolved
Hide resolved
crates/shared/src/price_estimation/trade_verifier/balance_overrides/detector.rs
Outdated
Show resolved
Hide resolved
| "detected balance slot via trace (single SLOAD) for token {:?}: slot {:?}", | ||
| token, slot | ||
| ); | ||
| return Ok(Strategy::DirectSlot { slot }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i agree
crates/shared/src/price_estimation/trade_verifier/balance_overrides/detector.rs
Outdated
Show resolved
Hide resolved
| // 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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
- use
debug_traceCall/eth_createAccessListto figure out the relevant storage slots - optionally use the brute forcing stuff to find more likely candidates
- find the EXACT storage slot for the given owner
- use the brute force logic to see if we can find the storage slot that holds the balances mapping
if that works returnSolidityMappingwith the found mapping slot
else returnDirectSlot
Step 4 should also check the Solady mapping but IIRC that only requires 1 check.
There was a problem hiding this comment.
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)
…wprotocol/services into feat/tracing-balance-override
for now not intermingling this with the SolidityMapper strategy because we are likely going to refactor this out in the future according to discussions.
…wprotocol/services into feat/tracing-balance-override
crates/shared/src/price_estimation/trade_verifier/balance_overrides/detector.rs
Outdated
Show resolved
Hide resolved
…rides/detector.rs Co-authored-by: Jan [Yann] <[email protected]>
| storage_slots.sort_by(|a, b| { | ||
| heuristic_slots | ||
| .contains(&b.1) | ||
| .cmp(&heuristic_slots.contains(&a.1)) | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
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
DirectSlotis added to detect the storage slot through a singledebug_traceCallon the ERC20balanceOffunction, and reading the resolvedSLOADslots. This approach is very similar to one followed by Foundry for theirdealcheatcode.This method has advantages:
SLOADin abalanceOfcall (most cases), only a singledebug_traceCallis 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.uint256will 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
transfercall) to the needed account as required.This method requires a node that supports the
debug_traceCallAPI. I believe this is the case for our infra, but please double check 🙏.For now I left the original
SolidityMappingdetector strategy as a backup in caseDirectSlotfails. However, there should theoretically be no cases whereSolidityMappingwould detect whenDirectSlotdoes not, so it would be worth removing and relying solely onDirectSlot.I added a E2E test and contract to validate that the storage slot detection is working as expected.
Changes
DirectSlotdetectorHow to test
Run
just test-e2e local_node_trace_based_balance_detection