|
34 | 34 | #
|
35 | 35 | # [Pre-deployed ERC20 Contract] ──── Storage slots for balances
|
36 | 36 | # │
|
37 |
| -# │ balanceOf(address) → SLOAD(keccak256(address || slot)) |
| 37 | +# ��� balanceOf(address) → SLOAD(keccak256(address || slot)) |
38 | 38 | # │
|
39 | 39 | # [Attack Contract] ──CALL──► ERC20.balanceOf(random_address)
|
40 | 40 | # │
|
@@ -175,3 +175,130 @@ def test_sload_empty_erc20_balanceof(
|
175 | 175 | blocks=[Block(txs=[attack_tx])],
|
176 | 176 | post=post,
|
177 | 177 | )
|
| 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