-
Notifications
You must be signed in to change notification settings - Fork 179
feat(benchmark): add SLOAD/SSTORE benchmark test with multi-contract support #2256
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?
feat(benchmark): add SLOAD/SSTORE benchmark test with multi-contract support #2256
Conversation
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.
Did a quick pass and it looks good to me overall.
I left a couple of questions as comments. Thanks!
pre: Alloc, | ||
fork: Fork, | ||
gas_benchmark_value: int, | ||
address_stubs, |
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 didn't quite expect the fixture to be directly used by the test tbh, it's an interesting workaround!
A couple things:
- It will produce unexpected behavior to someone running
execute
and not knowing the inner workings of this tests because it will try to use all the stubs, including those that are meant to be used by other tests. - The test will change its behavior depending on the stubs passed to the parameter in
execute
, which is not inherently a bad thing, but I think we should really think about it.
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.
Yes. I basically did that because a lot of the tests we will do will just be the same code, but changing the contract against which we run it.
Thus I thought it would not make sense to duplicate code all the time. And instead, I should just reuse the same code for any number of contracts that share interface.
So it's the stubs what actually determines how the contract behaves.
I spoke with @kamilchodola about this and he's also not sure if this way will be the best for his tool. Nevertheless, IMO it's the best in regards code to maintain and simplicity.
# 3. Most addresses have zero balance → empty storage slots | ||
# | ||
# WHY IT STRESSES CLIENTS: | ||
# - Each balanceOf() call forces a cold SLOAD on a likely-empty 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.
How different would this be from a benchmark test that optimizes for SLOAD
s instead of trying to mimic the balanceOf()
behavior?
The things that come to mind are the extra jumps, keccak operations, and the fact that balanceOf()
(iirc) only does a single SLOAD
per subcall.
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.
The problem we have here is the following:
- The attack wants to stress calling big contracts and doing path resolution on them (at least usually).
- The biggest contracts (that share interface) are ERC20. And
balanceOf
doesn't really add a ton of overhead. - If we had to deploy contracts that allow us to abuse SLOAD, we would need a ton of time to bloat lots of contracts with the same interface and make them 5-20 GB of storage each.
- Thus, for this iteration, it just seems significantly easier to go this route.
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.
Do you mean that, in your benchmarking process, the pre-deployed contracts already contain randomized storage values (such as balances or approvals), and you’re benchmarking the SLOAD
operation based on that?
I’ve read the recent state analysis report, which shows that USDT
is one of the largest contracts in terms of storage state. In your case, would this be similar to benchmarking state operations using such a large contract as a reference?
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.
They don't contain it. But they target the biggest ERC20 contracts on the chain. And ERC20 gives me a common interface to call them which I exploit here.
Otherwise, I'd need to deploy and bloat contracts all the time to perform these tests (which I might do in the future, but not now).
# RETURN costs 0 gas | ||
) | ||
|
||
num_contracts = len(address_stubs.root) |
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 we have to do the pattern (erc20_contract_*
) discrimination here to have a proper number.
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.
Why is that? I was under the assumption that all ERC20s will share the same interface. Though it's true they might have overwritten it, balanceOf
seems to not make sense to modify.
Could you elaborate a bit?
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.
For example, if we run execute for all tests, and therefore pass stub contracts that are needed for other tests (like xen_contract
for example), these other contracts are going to be included in address_stubs
unconditionally, and we are going to try to send a balanceOf
to those other contracts that are not ERC20 contracts.
# In execute mode: stubs point to already-deployed contracts on chain | ||
# In fill mode: empty bytecode is deployed as placeholder | ||
erc20_addresses = [] | ||
for stub_name in address_stubs.root: |
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 loop also needs to discriminate using the pattern.
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 have the same question here. Can you elaborate on this a bit?
BloatNet SSTORE benchmark using ERC20 approve to write to storage. | ||
|
||
This test: | ||
1. Auto-discovers ERC20 contracts from stubs (pattern: erc20_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.
I think we might not need the pattern at all.
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.
Some small suggestion and question, you could ignore these comment if it does not make sense!
+ Op.MSTORE(offset=64, value=Op.MLOAD(0)) | ||
# Store amount at memory[96] (use counter as amount) | ||
+ Op.MSTORE(offset=96, value=Op.MLOAD(0)) |
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.
Just a random idea, not a proposed change, but maybe Op.GAS
could also be used for the spender address and amount? It’s non-sequential and might be slightly cheaper than MLOAD(0)
.
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.
Notice this won't work because here this is acting as:
- As the loop counter (decrement)
- As the address/spender value
Using GAS would give non-deterministic values and seems much harder to turn deterministic imo.
LMK if I missunderstood you.
Add test_sload_empty_erc20_balanceof to benchmark SLOAD operations on non-existing storage slots using ERC20 balanceOf() queries. The idea of this benchmark is to exploit within a single or series of N contracts calls to non-existing addresses. On this way, we force clients to resolve as many tree branches as possible.
Add test_sstore_erc20_approve that benchmarks SSTORE operations by calling approve(spender, amount) on pre-deployed ERC20 contracts. Follows the same pattern as the SLOAD benchmark: - Auto-discovers ERC20 contracts from stubs - Splits gas budget evenly across all discovered contracts - Uses counter as both spender address and amount - Forces SSTOREs to allowance mapping storage slots The test measures client performance when writing to many storage slots across multiple contracts, stressing state-handling write operations.
Fixed gas calculation for test_sstore_erc20_approve to ensure accurate gas usage prediction and prevent transaction reverts: Key fixes: - Added memory expansion cost (15 gas per contract) - Corrected G_LOW gas values in comments (5 gas, not 3) - Separated per-contract overhead from per-iteration costs - Improved cost calculation clarity with detailed opcode breakdown Gas calculation (10M gas, 3 contracts): - Intrinsic: 21,000 - Overhead per contract: 38 - Cost per iteration: 20,226 - Calls per contract: 164 - Expected gas used: 9,972,306 (99.72% utilization)
…atios Add test_mixed_sload_sstore to test_multi_opcode.py that combines SLOAD and SSTORE operations with parameterized gas distribution ratios (50-50, 70-30, 90-10). The test stresses clients with mixed read/write workloads by: - Dividing gas budget evenly across all discovered ERC20 contract stubs - Splitting each contract's allocation by the specified percentage ratio - Executing balanceOf (cold SLOAD on empty slots) for the SLOAD portion - Executing approve (SSTORE to new allowance slots) for the SSTORE portion Verified gas calculations for 10M gas budget with 3 contracts (50-50 ratio): - SLOAD operations: ~2,312 gas/iteration → 719 calls per contract - SSTORE operations: ~20,226 gas/iteration → 82 calls per contract - Total operations: 2,403 state operations (2,157 SLOADs + 246 SSTOREs) - Gas usage: 9.98M / 10M (16K buffer, no out-of-gas errors) This benchmark enables testing different read/write ratios to identify client performance characteristics under varying state operation mixes.
…back Address review comments by optimizing loop efficiency: 1. Move function selector MSTORE outside loops (Comment ethereum#2) - BALANCEOF_SELECTOR and APPROVE_SELECTOR now stored once per contract - Saves 3 gas (G_VERY_LOW) per iteration - Total savings: ~6,471 gas for 50-50 ratio with 10M budget and 3 contracts 2. Remove unused return data from CALL operations (Comment ethereum#1) - Changed ret_offset=96/128, ret_size=32 to ret_offset=0, ret_size=0 - Eliminates unnecessary memory expansion - Minor gas savings, cleaner implementation Skipped Comment ethereum#3 (use Op.GAS for addresses): - Would lose determinism (GAS varies per iteration) - Adds complexity for minimal benefit - Counter still needed for loop control Changes applied to: - test_sload_empty_erc20_balanceof - test_sstore_erc20_approve - test_mixed_sload_sstore (both SLOAD and SSTORE loops)
e0ae1ee
to
552638e
Compare
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.
@CPerezz , I’ve left some suggestions. Please take a look and let me know if they’re unclear or not practical. These changes might not reduce much gas usage, but i wonder if they could help simplify the layout a bit.
I've not yet reviewed test_multi_opcode.py
, but i believe it would be quick if we have consensus on the other test cases!
) | ||
|
||
# Build attack code that loops through each contract | ||
attack_code: Bytecode = Op.JUMPDEST # Entry point |
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 have a suggestion for the attack loop: (1) the current memory layout duplicates the same counter value in two locations (e.g. MEM[0]
and MEM[64]
). (2) The memory storage for balance selector could further be taken out of the for-loop, as it is always a constant.
attack_code = Op.MSTORE(offset=0, value=BALANCE_SELECTOR) # This do not need to be inside the for loop as it is constant
for erc20_address in erc20_addresses:
attack_code += Op.MSTORE(offset=32, value=calls_per_contract)
+ While(
condition=Op.MLOAD(32) + Op.ISZERO + Op.ISZERO, # Continue while counter > 0
body=(
+ Op.CALL(
address=erc20_address,
args_offset=28,
args_size=36,
)
+ Op.POP
+ Op.MSTORE(offset=32, value=Op.SUB(Op.MLOAD(32), 1))
),
)
In this implementation, we use MEM[32]
for the counter, and only store the balance selector once. Do you think this works in the current scenario?
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.
The offset of CALL's parameter might be slightly different, please see comments below
+ Op.CALL( | ||
address=erc20_address, | ||
value=0, | ||
args_offset=32, | ||
args_size=36, | ||
ret_offset=0, | ||
ret_size=0, | ||
) |
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.
IIUC, a typical Solidity calldata pattern consists of a function selector followed by ABI-encoded arguments.
Considering only the first iteration of the while loop, the memory layout would be:
MSTORE(0, counter)
MSTORE(32, BALANCE_SELECTOR)
MSTORE(64, counter)
Assuming the counter value is 3, i tried out this memory sequence on evm.codes. (The plyaground with mnemonic input)
PUSH4 0x70A08231
PUSH1 0x20
MSTORE
PUSH2 0x0003
PUSH0
MSTORE
PUSH0
MLOAD
PUSH1 0x40
MSTORE
And i get this memory layout:
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000070a08231
0000000000000000000000000000000000000000000000000000000000000003
It seems memory is left-padded, so the correct starting offset here for the external call might be 32 + 32 - 4 = 60, rather than 32.
Similarly, the starting offset of external call for the previous comment is 28, not 0.
+ Op.CALL( | ||
address=erc20_address, | ||
value=0, | ||
args_offset=32, | ||
args_size=68, # 4 bytes selector + 32 bytes spender + 32 bytes amount | ||
ret_offset=0, | ||
ret_size=0, | ||
) |
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 have the same question about the args_offset
here, should it start from 60, not 32 here?
) | ||
|
||
# Build attack code that loops through each contract | ||
attack_code: Bytecode = Op.JUMPDEST # Entry point |
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.
Same suggestion here:
- Do you think we could simplify the memory layout here?
- Could we move the SELECTOR memory operation out of the for-loop?
…alldata encoding - Move selector MSTORE outside for-loop (saves gas per contract) - Use single counter at MEM[32] instead of duplicate at MEM[0] and MEM[64] - Fix calldata encoding by using args_offset=28 for correct ABI format - Selector now properly positioned at start of calldata
…calldata encoding - Move selector MSTORE outside for-loop (saves gas per contract) - Use single counter at MEM[32] instead of duplicate at MEM[0] - Fix calldata encoding by using args_offset=28 for correct ABI format - Selector now properly positioned at start of calldata
…x calldata encoding - Move selectors MSTORE outside for-loop (saves gas per contract) - Use separate memory regions for balanceOf and approve to avoid conflicts - Fix calldata encoding by using correct args_offset for proper ABI format - Selectors now properly positioned at start of calldata
…stently - Reuse MEM[0] for both selectors (sequential operations, no conflict) - Reuse MEM[32] for both counters (balanceOf then approve) - Reuse MEM[64] and MEM[96] for parameters - Consistent args_offset=28 for both operations (was 28 and 128) - Matches single-opcode test pattern for easier understanding - Reduces memory footprint from 196 bytes to 96 bytes
🗒️ Description
Add test_sload_empty_erc20_balanceof to benchmark SLOAD operations on non-existing storage slots using ERC20 balanceOf() queries.
The idea of this benchmark is to exploit within a single or series of N contracts calls to non-existing addresses. On this way, we force clients to resolve as many tree branches as possible.
✅ Checklist
tox
checks to avoid unnecessary CI fails, see also Code Standards and Enabling Pre-commit Checks:uvx --with=tox-uv tox -e lint,typecheck,spellcheck,markdownlint
type(scope):
.mkdocs serve
locally and verified the auto-generated docs for new tests in the Test Case Reference are correctly formatted.@ported_from
marker.