Skip to content

Commit 665a8c0

Browse files
committed
feat(benchmark): add mixed SLOAD/SSTORE benchmark with configurable ratios
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.
1 parent e3bee3d commit 665a8c0

File tree

1 file changed

+217
-0
lines changed

1 file changed

+217
-0
lines changed

tests/benchmark/stateful/bloatnet/test_multi_opcode.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,3 +313,220 @@ def test_bloatnet_balance_extcodecopy(
313313
blocks=[Block(txs=[attack_tx])],
314314
post=post,
315315
)
316+
317+
318+
# ERC20 function selectors
319+
BALANCEOF_SELECTOR = 0x70A08231 # balanceOf(address)
320+
APPROVE_SELECTOR = 0x095EA7B3 # approve(address,uint256)
321+
322+
323+
@pytest.mark.valid_from("Prague")
324+
@pytest.mark.parametrize(
325+
"sload_percent,sstore_percent",
326+
[
327+
pytest.param(50, 50, id="50-50"),
328+
pytest.param(70, 30, id="70-30"),
329+
pytest.param(90, 10, id="90-10"),
330+
],
331+
)
332+
def test_mixed_sload_sstore(
333+
blockchain_test: BlockchainTestFiller,
334+
pre: Alloc,
335+
fork: Fork,
336+
gas_benchmark_value: int,
337+
address_stubs,
338+
sload_percent: int,
339+
sstore_percent: int,
340+
):
341+
"""
342+
BloatNet mixed SLOAD/SSTORE benchmark with configurable operation ratios.
343+
344+
This test:
345+
1. Auto-discovers ERC20 contracts from stubs
346+
2. Divides gas budget evenly across all contracts
347+
3. For each contract, divides gas into SLOAD and SSTORE portions by
348+
percentage
349+
4. Executes balanceOf (SLOAD) and approve (SSTORE) calls per the ratio
350+
5. Stresses clients with combined read/write operations on large
351+
contracts
352+
"""
353+
gas_costs = fork.gas_costs()
354+
355+
# Calculate gas costs
356+
intrinsic_gas = fork.transaction_intrinsic_cost_calculator()(calldata=b"")
357+
358+
num_contracts = len(address_stubs.root)
359+
360+
# Cost per SLOAD iteration (balanceOf call)
361+
sload_cost_per_iteration = (
362+
# Attack contract loop overhead
363+
gas_costs.G_VERY_LOW * 2 # MLOAD counter (3*2)
364+
+ gas_costs.G_VERY_LOW * 2 # MSTORE selector (3*2)
365+
+ gas_costs.G_VERY_LOW * 3 # MLOAD + MSTORE address (3*3)
366+
+ gas_costs.G_BASE # POP (2)
367+
+ gas_costs.G_BASE * 3 # SUB + MLOAD + MSTORE for counter decrement (2*3)
368+
+ gas_costs.G_BASE * 2 # ISZERO * 2 for loop condition (2*2)
369+
+ gas_costs.G_MID # JUMPI (8)
370+
# CALL to ERC20 contract
371+
+ gas_costs.G_WARM_ACCOUNT_ACCESS # Warm CALL to same contract (100)
372+
# Inside ERC20 balanceOf
373+
+ gas_costs.G_VERY_LOW # PUSH4 selector (3)
374+
+ gas_costs.G_BASE # EQ selector match (2)
375+
+ gas_costs.G_MID # JUMPI to function (8)
376+
+ gas_costs.G_JUMPDEST # JUMPDEST at function start (1)
377+
+ gas_costs.G_VERY_LOW * 2 # CALLDATALOAD arg (3*2)
378+
+ gas_costs.G_KECCAK_256 # keccak256 static (30)
379+
+ gas_costs.G_KECCAK_256_WORD * 2 # keccak256 dynamic for 64 bytes (2*6)
380+
+ gas_costs.G_COLD_SLOAD # Cold SLOAD (2100)
381+
+ gas_costs.G_VERY_LOW * 3 # MSTORE result + RETURN setup (3*3)
382+
)
383+
384+
# Cost per SSTORE iteration (approve call)
385+
sstore_cost_per_iteration = (
386+
# Attack contract loop body operations
387+
gas_costs.G_VERY_LOW # MSTORE selector at memory[32] (3)
388+
+ gas_costs.G_LOW # MLOAD counter (5)
389+
+ gas_costs.G_VERY_LOW # MSTORE spender at memory[64] (3)
390+
+ gas_costs.G_LOW # MLOAD counter (5)
391+
+ gas_costs.G_VERY_LOW # MSTORE amount at memory[96] (3)
392+
# CALL to ERC20 contract
393+
+ gas_costs.G_WARM_ACCOUNT_ACCESS # Warm CALL base cost (100)
394+
+ gas_costs.G_BASE # POP call result (2)
395+
# Counter decrement
396+
+ gas_costs.G_LOW # MLOAD counter (5)
397+
+ gas_costs.G_VERY_LOW # PUSH1 1 (3)
398+
+ gas_costs.G_VERY_LOW # SUB (3)
399+
+ gas_costs.G_VERY_LOW # MSTORE counter back (3)
400+
# While loop condition check
401+
+ gas_costs.G_LOW # MLOAD counter (5)
402+
+ gas_costs.G_BASE # ISZERO (2)
403+
+ gas_costs.G_BASE # ISZERO (2)
404+
+ gas_costs.G_MID # JUMPI back to loop start (8)
405+
# Inside ERC20 approve function
406+
+ gas_costs.G_VERY_LOW # PUSH4 selector (3)
407+
+ gas_costs.G_BASE # EQ selector match (2)
408+
+ gas_costs.G_MID # JUMPI to function (8)
409+
+ gas_costs.G_JUMPDEST # JUMPDEST at function start (1)
410+
+ gas_costs.G_VERY_LOW # CALLDATALOAD spender (3)
411+
+ gas_costs.G_VERY_LOW # CALLDATALOAD amount (3)
412+
+ gas_costs.G_KECCAK_256 # keccak256 static (30)
413+
+ gas_costs.G_KECCAK_256_WORD * 2 # keccak256 dynamic for 64 bytes (12)
414+
+ gas_costs.G_STORAGE_SET # SSTORE to zero slot (20000)
415+
+ gas_costs.G_VERY_LOW # PUSH1 1 for return value (3)
416+
+ gas_costs.G_VERY_LOW # MSTORE return value (3)
417+
+ gas_costs.G_VERY_LOW # PUSH1 32 for return size (3)
418+
+ gas_costs.G_VERY_LOW # PUSH1 0 for return offset (3)
419+
)
420+
421+
# Calculate gas budget per contract
422+
available_gas = gas_benchmark_value - intrinsic_gas
423+
gas_per_contract = available_gas // num_contracts
424+
425+
# For each contract, split gas by percentage
426+
sload_gas_per_contract = (gas_per_contract * sload_percent) // 100
427+
sstore_gas_per_contract = (gas_per_contract * sstore_percent) // 100
428+
429+
# Calculate calls per contract per operation
430+
sload_calls_per_contract = int(sload_gas_per_contract // sload_cost_per_iteration)
431+
sstore_calls_per_contract = int(sstore_gas_per_contract // sstore_cost_per_iteration)
432+
433+
# Deploy all discovered ERC20 contracts using stubs
434+
erc20_addresses = []
435+
for stub_name in address_stubs.root:
436+
addr = pre.deploy_contract(
437+
code=Bytecode(),
438+
stub=stub_name,
439+
)
440+
erc20_addresses.append(addr)
441+
442+
# Log test requirements
443+
print(
444+
f"Total gas budget: {gas_benchmark_value / 1_000_000:.1f}M gas. "
445+
f"~{gas_per_contract / 1_000_000:.1f}M gas per contract "
446+
f"({sload_percent}% SLOAD, {sstore_percent}% SSTORE). "
447+
f"Per contract: {sload_calls_per_contract} balanceOf calls, "
448+
f"{sstore_calls_per_contract} approve calls."
449+
)
450+
451+
# Build attack code that loops through each contract
452+
attack_code: Bytecode = Op.JUMPDEST # Entry point
453+
454+
for erc20_address in erc20_addresses:
455+
# For each contract, execute SLOAD operations (balanceOf)
456+
attack_code += (
457+
# Initialize counter in memory[0] = number of balanceOf calls
458+
Op.MSTORE(offset=0, value=sload_calls_per_contract)
459+
# Loop for balanceOf calls
460+
+ While(
461+
condition=Op.MLOAD(0) + Op.ISZERO + Op.ISZERO,
462+
body=(
463+
# Store function selector at memory[32]
464+
Op.MSTORE(offset=32, value=BALANCEOF_SELECTOR)
465+
# Store address at memory[64] (use counter as address)
466+
+ Op.MSTORE(offset=64, value=Op.MLOAD(0))
467+
# Call balanceOf(address) on ERC20 contract
468+
+ Op.CALL(
469+
address=erc20_address,
470+
value=0,
471+
args_offset=32,
472+
args_size=36,
473+
ret_offset=96,
474+
ret_size=32,
475+
)
476+
+ Op.POP # Discard result
477+
# Decrement counter
478+
+ Op.MSTORE(offset=0, value=Op.SUB(Op.MLOAD(0), 1))
479+
),
480+
)
481+
)
482+
483+
# For each contract, execute SSTORE operations (approve)
484+
attack_code += (
485+
# Initialize counter in memory[0] = number of approve calls
486+
Op.MSTORE(offset=0, value=sstore_calls_per_contract)
487+
# Loop for approve calls
488+
+ While(
489+
condition=Op.MLOAD(0) + Op.ISZERO + Op.ISZERO,
490+
body=(
491+
# Store function selector at memory[32]
492+
Op.MSTORE(offset=32, value=APPROVE_SELECTOR)
493+
# Store spender address at memory[64] (use counter)
494+
+ Op.MSTORE(offset=64, value=Op.MLOAD(0))
495+
# Store amount at memory[96] (use counter as amount)
496+
+ Op.MSTORE(offset=96, value=Op.MLOAD(0))
497+
# Call approve(spender, amount) on ERC20 contract
498+
+ Op.CALL(
499+
address=erc20_address,
500+
value=0,
501+
args_offset=32,
502+
args_size=68,
503+
ret_offset=128,
504+
ret_size=32,
505+
)
506+
+ Op.POP # Discard result
507+
# Decrement counter
508+
+ Op.MSTORE(offset=0, value=Op.SUB(Op.MLOAD(0), 1))
509+
),
510+
)
511+
)
512+
513+
# Deploy attack contract
514+
attack_address = pre.deploy_contract(code=attack_code)
515+
516+
# Run the attack
517+
attack_tx = Transaction(
518+
to=attack_address,
519+
gas_limit=gas_benchmark_value,
520+
sender=pre.fund_eoa(),
521+
)
522+
523+
# Post-state
524+
post = {
525+
attack_address: Account(storage={}),
526+
}
527+
528+
blockchain_test(
529+
pre=pre,
530+
blocks=[Block(txs=[attack_tx])],
531+
post=post,
532+
)

0 commit comments

Comments
 (0)