Skip to content

Commit 4843bc3

Browse files
committed
feat(benchmark): add SSTORE benchmark test using ERC20 approve
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.
1 parent 5a1a966 commit 4843bc3

File tree

1 file changed

+128
-1
lines changed

1 file changed

+128
-1
lines changed

tests/benchmark/stateful/bloatnet/test_single_opcode.py

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
#
3535
# [Pre-deployed ERC20 Contract] ──── Storage slots for balances
3636
# │
37-
# balanceOf(address) → SLOAD(keccak256(address || slot))
37+
# ��� balanceOf(address) → SLOAD(keccak256(address || slot))
3838
# │
3939
# [Attack Contract] ──CALL──► ERC20.balanceOf(random_address)
4040
# │
@@ -175,3 +175,130 @@ def test_sload_empty_erc20_balanceof(
175175
blocks=[Block(txs=[attack_tx])],
176176
post=post,
177177
)
178+
179+
180+
@pytest.mark.valid_from("Prague")
181+
def test_sstore_erc20_approve(
182+
blockchain_test: BlockchainTestFiller,
183+
pre: Alloc,
184+
fork: Fork,
185+
gas_benchmark_value: int,
186+
address_stubs,
187+
):
188+
"""
189+
BloatNet SSTORE benchmark using ERC20 approve to write to storage.
190+
191+
This test:
192+
1. Auto-discovers ERC20 contracts from stubs (pattern: erc20_contract_*)
193+
2. Splits gas budget evenly across all discovered contracts
194+
3. Calls approve(spender, amount) incrementally (counter as spender)
195+
4. Forces SSTOREs to allowance mapping storage slots
196+
"""
197+
gas_costs = fork.gas_costs()
198+
199+
# Calculate gas costs
200+
intrinsic_gas = fork.transaction_intrinsic_cost_calculator()(calldata=b"")
201+
202+
# Cost per iteration (attack contract overhead + approve call)
203+
cost_per_iteration = (
204+
# Attack contract loop overhead
205+
gas_costs.G_VERY_LOW * 2 # MLOAD counter (3*2)
206+
+ gas_costs.G_VERY_LOW * 2 # MSTORE selector (3*2)
207+
+ gas_costs.G_VERY_LOW * 4 # MLOAD + MSTORE spender + amount (3*4)
208+
+ gas_costs.G_BASE # POP (2)
209+
+ gas_costs.G_BASE * 3 # SUB + MLOAD + MSTORE for counter decrement (2*3)
210+
+ gas_costs.G_BASE * 2 # ISZERO * 2 for loop condition (2*2)
211+
+ gas_costs.G_MID # JUMPI (8)
212+
# CALL to ERC20 contract
213+
+ gas_costs.G_WARM_ACCOUNT_ACCESS # Warm CALL to same contract (100)
214+
# Inside ERC20 approve
215+
+ gas_costs.G_VERY_LOW # PUSH4 selector (3)
216+
+ gas_costs.G_BASE # EQ selector match (2)
217+
+ gas_costs.G_MID # JUMPI to function (8)
218+
+ gas_costs.G_JUMPDEST # JUMPDEST at function start (1)
219+
+ gas_costs.G_VERY_LOW * 3 # CALLDATALOAD args (3*3)
220+
+ gas_costs.G_KECCAK_256 # keccak256 static (30)
221+
+ gas_costs.G_KECCAK_256_WORD * 2 # keccak256 dynamic for 64 bytes (2*6)
222+
+ gas_costs.G_STORAGE_SET # SSTORE to zero slot (20000)
223+
+ gas_costs.G_VERY_LOW * 3 # MSTORE result + RETURN setup (3*3)
224+
# RETURN costs 0 gas
225+
)
226+
227+
num_contracts = len(address_stubs.root)
228+
229+
# Calculate gas budget per contract and calls per contract
230+
available_gas = gas_benchmark_value - intrinsic_gas
231+
gas_per_contract = available_gas // num_contracts
232+
calls_per_contract = int(gas_per_contract // cost_per_iteration)
233+
234+
# Deploy all discovered ERC20 contracts using stubs
235+
erc20_addresses = []
236+
for stub_name in address_stubs.root:
237+
addr = pre.deploy_contract(
238+
code=Bytecode(),
239+
stub=stub_name,
240+
)
241+
erc20_addresses.append(addr)
242+
243+
# Log test requirements
244+
print(
245+
f"Total gas budget: {gas_benchmark_value / 1_000_000:.1f}M gas. "
246+
f"~{gas_per_contract / 1_000_000:.1f}M gas per contract, "
247+
f"{calls_per_contract} approve calls per contract."
248+
)
249+
250+
# Build attack code that loops through each contract
251+
attack_code: Bytecode = Op.JUMPDEST # Entry point
252+
253+
for erc20_address in erc20_addresses:
254+
# For each contract, initialize counter and loop
255+
attack_code += (
256+
# Initialize counter in memory[0] = number of calls
257+
Op.MSTORE(offset=0, value=calls_per_contract)
258+
# Loop for this specific contract
259+
+ While(
260+
condition=Op.MLOAD(0) + Op.ISZERO + Op.ISZERO, # Continue while counter > 0
261+
body=(
262+
# Use counter directly as spender address (cheapest option)
263+
# Store function selector at memory[32]
264+
Op.MSTORE(offset=32, value=APPROVE_SELECTOR)
265+
# Store spender address at memory[64] (use counter)
266+
+ Op.MSTORE(offset=64, value=Op.MLOAD(0))
267+
# Store amount at memory[96] (use counter as amount)
268+
+ Op.MSTORE(offset=96, value=Op.MLOAD(0))
269+
# Call approve(spender, amount) on ERC20 contract
270+
+ Op.CALL(
271+
address=erc20_address,
272+
value=0,
273+
args_offset=32,
274+
args_size=68, # 4 bytes selector + 32 bytes spender + 32 bytes amount
275+
ret_offset=128,
276+
ret_size=32,
277+
)
278+
+ Op.POP # Discard result
279+
# Decrement counter: counter - 1
280+
+ Op.MSTORE(offset=0, value=Op.SUB(Op.MLOAD(0), 1))
281+
),
282+
)
283+
)
284+
285+
# Deploy attack contract
286+
attack_address = pre.deploy_contract(code=attack_code)
287+
288+
# Run the attack
289+
attack_tx = Transaction(
290+
to=attack_address,
291+
gas_limit=gas_benchmark_value,
292+
sender=pre.fund_eoa(),
293+
)
294+
295+
# Post-state
296+
post = {
297+
attack_address: Account(storage={}),
298+
}
299+
300+
blockchain_test(
301+
pre=pre,
302+
blocks=[Block(txs=[attack_tx])],
303+
post=post,
304+
)

0 commit comments

Comments
 (0)