Skip to content

Commit b0a7aa0

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 5834cc6 commit b0a7aa0

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
@@ -465,3 +465,220 @@ def test_bloatnet_balance_extcodehash(
465465
blocks=[Block(txs=[attack_tx])],
466466
post=post,
467467
)
468+
469+
470+
# ERC20 function selectors
471+
BALANCEOF_SELECTOR = 0x70A08231 # balanceOf(address)
472+
APPROVE_SELECTOR = 0x095EA7B3 # approve(address,uint256)
473+
474+
475+
@pytest.mark.valid_from("Prague")
476+
@pytest.mark.parametrize(
477+
"sload_percent,sstore_percent",
478+
[
479+
pytest.param(50, 50, id="50-50"),
480+
pytest.param(70, 30, id="70-30"),
481+
pytest.param(90, 10, id="90-10"),
482+
],
483+
)
484+
def test_mixed_sload_sstore(
485+
blockchain_test: BlockchainTestFiller,
486+
pre: Alloc,
487+
fork: Fork,
488+
gas_benchmark_value: int,
489+
address_stubs,
490+
sload_percent: int,
491+
sstore_percent: int,
492+
):
493+
"""
494+
BloatNet mixed SLOAD/SSTORE benchmark with configurable operation ratios.
495+
496+
This test:
497+
1. Auto-discovers ERC20 contracts from stubs
498+
2. Divides gas budget evenly across all contracts
499+
3. For each contract, divides gas into SLOAD and SSTORE portions by
500+
percentage
501+
4. Executes balanceOf (SLOAD) and approve (SSTORE) calls per the ratio
502+
5. Stresses clients with combined read/write operations on large
503+
contracts
504+
"""
505+
gas_costs = fork.gas_costs()
506+
507+
# Calculate gas costs
508+
intrinsic_gas = fork.transaction_intrinsic_cost_calculator()(calldata=b"")
509+
510+
num_contracts = len(address_stubs.root)
511+
512+
# Cost per SLOAD iteration (balanceOf call)
513+
sload_cost_per_iteration = (
514+
# Attack contract loop overhead
515+
gas_costs.G_VERY_LOW * 2 # MLOAD counter (3*2)
516+
+ gas_costs.G_VERY_LOW * 2 # MSTORE selector (3*2)
517+
+ gas_costs.G_VERY_LOW * 3 # MLOAD + MSTORE address (3*3)
518+
+ gas_costs.G_BASE # POP (2)
519+
+ gas_costs.G_BASE * 3 # SUB + MLOAD + MSTORE for counter decrement (2*3)
520+
+ gas_costs.G_BASE * 2 # ISZERO * 2 for loop condition (2*2)
521+
+ gas_costs.G_MID # JUMPI (8)
522+
# CALL to ERC20 contract
523+
+ gas_costs.G_WARM_ACCOUNT_ACCESS # Warm CALL to same contract (100)
524+
# Inside ERC20 balanceOf
525+
+ gas_costs.G_VERY_LOW # PUSH4 selector (3)
526+
+ gas_costs.G_BASE # EQ selector match (2)
527+
+ gas_costs.G_MID # JUMPI to function (8)
528+
+ gas_costs.G_JUMPDEST # JUMPDEST at function start (1)
529+
+ gas_costs.G_VERY_LOW * 2 # CALLDATALOAD arg (3*2)
530+
+ gas_costs.G_KECCAK_256 # keccak256 static (30)
531+
+ gas_costs.G_KECCAK_256_WORD * 2 # keccak256 dynamic for 64 bytes (2*6)
532+
+ gas_costs.G_COLD_SLOAD # Cold SLOAD (2100)
533+
+ gas_costs.G_VERY_LOW * 3 # MSTORE result + RETURN setup (3*3)
534+
)
535+
536+
# Cost per SSTORE iteration (approve call)
537+
sstore_cost_per_iteration = (
538+
# Attack contract loop body operations
539+
gas_costs.G_VERY_LOW # MSTORE selector at memory[32] (3)
540+
+ gas_costs.G_LOW # MLOAD counter (5)
541+
+ gas_costs.G_VERY_LOW # MSTORE spender at memory[64] (3)
542+
+ gas_costs.G_LOW # MLOAD counter (5)
543+
+ gas_costs.G_VERY_LOW # MSTORE amount at memory[96] (3)
544+
# CALL to ERC20 contract
545+
+ gas_costs.G_WARM_ACCOUNT_ACCESS # Warm CALL base cost (100)
546+
+ gas_costs.G_BASE # POP call result (2)
547+
# Counter decrement
548+
+ gas_costs.G_LOW # MLOAD counter (5)
549+
+ gas_costs.G_VERY_LOW # PUSH1 1 (3)
550+
+ gas_costs.G_VERY_LOW # SUB (3)
551+
+ gas_costs.G_VERY_LOW # MSTORE counter back (3)
552+
# While loop condition check
553+
+ gas_costs.G_LOW # MLOAD counter (5)
554+
+ gas_costs.G_BASE # ISZERO (2)
555+
+ gas_costs.G_BASE # ISZERO (2)
556+
+ gas_costs.G_MID # JUMPI back to loop start (8)
557+
# Inside ERC20 approve function
558+
+ gas_costs.G_VERY_LOW # PUSH4 selector (3)
559+
+ gas_costs.G_BASE # EQ selector match (2)
560+
+ gas_costs.G_MID # JUMPI to function (8)
561+
+ gas_costs.G_JUMPDEST # JUMPDEST at function start (1)
562+
+ gas_costs.G_VERY_LOW # CALLDATALOAD spender (3)
563+
+ gas_costs.G_VERY_LOW # CALLDATALOAD amount (3)
564+
+ gas_costs.G_KECCAK_256 # keccak256 static (30)
565+
+ gas_costs.G_KECCAK_256_WORD * 2 # keccak256 dynamic for 64 bytes (12)
566+
+ gas_costs.G_STORAGE_SET # SSTORE to zero slot (20000)
567+
+ gas_costs.G_VERY_LOW # PUSH1 1 for return value (3)
568+
+ gas_costs.G_VERY_LOW # MSTORE return value (3)
569+
+ gas_costs.G_VERY_LOW # PUSH1 32 for return size (3)
570+
+ gas_costs.G_VERY_LOW # PUSH1 0 for return offset (3)
571+
)
572+
573+
# Calculate gas budget per contract
574+
available_gas = gas_benchmark_value - intrinsic_gas
575+
gas_per_contract = available_gas // num_contracts
576+
577+
# For each contract, split gas by percentage
578+
sload_gas_per_contract = (gas_per_contract * sload_percent) // 100
579+
sstore_gas_per_contract = (gas_per_contract * sstore_percent) // 100
580+
581+
# Calculate calls per contract per operation
582+
sload_calls_per_contract = int(sload_gas_per_contract // sload_cost_per_iteration)
583+
sstore_calls_per_contract = int(sstore_gas_per_contract // sstore_cost_per_iteration)
584+
585+
# Deploy all discovered ERC20 contracts using stubs
586+
erc20_addresses = []
587+
for stub_name in address_stubs.root:
588+
addr = pre.deploy_contract(
589+
code=Bytecode(),
590+
stub=stub_name,
591+
)
592+
erc20_addresses.append(addr)
593+
594+
# Log test requirements
595+
print(
596+
f"Total gas budget: {gas_benchmark_value / 1_000_000:.1f}M gas. "
597+
f"~{gas_per_contract / 1_000_000:.1f}M gas per contract "
598+
f"({sload_percent}% SLOAD, {sstore_percent}% SSTORE). "
599+
f"Per contract: {sload_calls_per_contract} balanceOf calls, "
600+
f"{sstore_calls_per_contract} approve calls."
601+
)
602+
603+
# Build attack code that loops through each contract
604+
attack_code: Bytecode = Op.JUMPDEST # Entry point
605+
606+
for erc20_address in erc20_addresses:
607+
# For each contract, execute SLOAD operations (balanceOf)
608+
attack_code += (
609+
# Store function selector at memory[32] (once per contract)
610+
Op.MSTORE(offset=32, value=BALANCEOF_SELECTOR)
611+
# Initialize counter in memory[0] = number of balanceOf calls
612+
+ Op.MSTORE(offset=0, value=sload_calls_per_contract)
613+
# Loop for balanceOf calls
614+
+ While(
615+
condition=Op.MLOAD(0) + Op.ISZERO + Op.ISZERO,
616+
body=(
617+
# Store address at memory[64] (use counter as address)
618+
Op.MSTORE(offset=64, value=Op.MLOAD(0))
619+
# Call balanceOf(address) on ERC20 contract
620+
+ Op.CALL(
621+
address=erc20_address,
622+
value=0,
623+
args_offset=32,
624+
args_size=36,
625+
ret_offset=0,
626+
ret_size=0,
627+
)
628+
+ Op.POP # Discard result
629+
# Decrement counter
630+
+ Op.MSTORE(offset=0, value=Op.SUB(Op.MLOAD(0), 1))
631+
),
632+
)
633+
)
634+
635+
# For each contract, execute SSTORE operations (approve)
636+
attack_code += (
637+
# Store function selector at memory[32] (once per contract)
638+
Op.MSTORE(offset=32, value=APPROVE_SELECTOR)
639+
# Initialize counter in memory[0] = number of approve calls
640+
+ Op.MSTORE(offset=0, value=sstore_calls_per_contract)
641+
# Loop for approve calls
642+
+ While(
643+
condition=Op.MLOAD(0) + Op.ISZERO + Op.ISZERO,
644+
body=(
645+
# Store spender address at memory[64] (use counter)
646+
Op.MSTORE(offset=64, value=Op.MLOAD(0))
647+
# Store amount at memory[96] (use counter as amount)
648+
+ Op.MSTORE(offset=96, value=Op.MLOAD(0))
649+
# Call approve(spender, amount) on ERC20 contract
650+
+ Op.CALL(
651+
address=erc20_address,
652+
value=0,
653+
args_offset=32,
654+
args_size=68,
655+
ret_offset=0,
656+
ret_size=0,
657+
)
658+
+ Op.POP # Discard result
659+
# Decrement counter
660+
+ Op.MSTORE(offset=0, value=Op.SUB(Op.MLOAD(0), 1))
661+
),
662+
)
663+
)
664+
665+
# Deploy attack contract
666+
attack_address = pre.deploy_contract(code=attack_code)
667+
668+
# Run the attack
669+
attack_tx = Transaction(
670+
to=attack_address,
671+
gas_limit=gas_benchmark_value,
672+
sender=pre.fund_eoa(),
673+
)
674+
675+
# Post-state
676+
post = {
677+
attack_address: Account(storage={}),
678+
}
679+
680+
blockchain_test(
681+
pre=pre,
682+
blocks=[Block(txs=[attack_tx])],
683+
post=post,
684+
)

0 commit comments

Comments
 (0)