Skip to content

Add automatic gas cost calculation for opcode sequences #2273

@LouisTsai-Csie

Description

@LouisTsai-Csie

Description

Many benchmark tests currently require manually calculating the maximum iteration count based on the gas cost of each opcode sequence per loop iteration.

For example, in PR #2256, we first calculate the gas cost for every operation to determine the per-iteration cost, then derive the maximum iteration count given the current gas benchmark value.

This process introduces several drawbacks:

  1. It’s more intuitive to first define the opcode sequence and then compute its gas usage, rather than manually calculating gas before writing the sequence.
  2. Contributors must manually compute gas costs, and reviewers need to verify them opcode by opcode against the gas table, which is tedious and can easily be wrong.
  3. Helper functions, which used to simplify test logic, like While object, obscure the underlying opcodes, making gas estimation harder to reason about.
  4. Future gas repricing (e.g. eip-7904) will break all existing benchmarks that rely on hardcoded legacy gas values.

Example:

For a sequence such as:

MSTORE(0, 3)
MSTORE(32, 0x70A08231)
MSTORE(64, MLOAD(0))

You must first expand it to raw opcodes (in your mind):

PUSH4 0x70A08231
PUSH1 0x20
MSTORE
PUSH2 0x0003
PUSH1 0x00
MSTORE
PUSH1 0x00
MLOAD
PUSH1 0x40
MSTORE

Then compute the gas cost manually:

G_VERY_LOW * 6  # PUSH
G_VERY_LOW * 3  # MSTORE
G_LOW * 1       # MLOAD

This manual process is repetitive and prone to human error, especially when the code changes frequently during review.

Proposed Solution

Introduce an attribute (or method) for the Bytecode object or a new helper function that automatically computes gas usage for an opcode sequence.

Example:

opcode = MSTORE(0, 3) + MSTORE(32, 0x70A08231) + MSTORE(64, MLOAD(0))
print(opcode.gas)  # Automatically calculate gas cost

This feature would allow benchmark tests to focus on logical construction rather than manual accounting, improving readability and maintainability.

Refactored Example (test_worst_selfdestruct_initcode)
Instead of this:

# Manual gas calculation
initcode_costs = gas_costs.G_BASE + gas_costs.G_SELF_DESTRUCT
prefix_cost = gas_costs.G_VERY_LOW * 3 + gas_costs.G_BASE + memory_expansion_calc(new_bytes=32)
suffix_cost = gas_costs.G_COLD_SLOAD + gas_costs.G_STORAGE_RESET + gas_costs.G_VERY_LOW * 2
base_costs = prefix_cost + suffix_cost
initcode = Op.SELFDESTRUCT(Op.COINBASE)
code_prefix = Op.MSTORE(0, initcode.hex()) + Op.PUSH0 + Op.JUMPDEST
code_suffix = Op.SSTORE(0, 42) + Op.STOP

We could write:

initcode = Op.SELFDESTRUCT(Op.COINBASE)
code_prefix = Op.MSTORE(0, initcode.hex()) + Op.PUSH0 + Op.JUMPDEST
code_suffix = Op.SSTORE(0, 42) + Op.STOP
base_costs = code_prefix.gas + code_suffix.gas

Potential issue

Dynamic gas cost calculation

Gas costs for certain EVM operations are not fixed, they vary depending on the execution context.

  1. Storage operations have different gas costs for warm and cold accesses, as well as for storage resets.
  2. Memory-related operations (e.g., SHA3) incur additional dynamic costs based on memory expansion.

Currently, these contextual variations make it difficult to perform accurate and automated gas calculations.

In the following case, the SSTORE operation should be recognized as a storage reset scenario. We could annotate it with a label to guide gas cost computation:

code_suffix = (
    Op.SSTORE(offset=0, value42, label=STORAGE_RESET)
    + Op.STOP
)

For operations like SHA3, the gas cost depends on the word size and memory expansion. A labeled or parameterized approach could help express this more explicitly:

attack_call = Op.POP(
    opcode(address=Op.SHA3(offset=32 - 20 - 1, size=85, word_size=85))
)

# Gas cost calculation:
# gas_costs.G_KECCAK_256                     # Static KECCAK cost
# + math.ceil(word_size / 32) * gas_costs.G_KECCAK_256_WORD  # Dynamic memory cost

More cases here needs to resolve

Duplication opcode sequence

Although we are be able to simplify the gas cost logic like:

attack_code = MSTORE(0, 32) + MSTORE(32, BALANCE_SELECTOR) + MSTORE(64, 32)
iteration_count = gas_benchmark_value // attack_code.gas

actual_attack_code = MSTORE(0, iteration_count) + MSTORE(32, BALANCE_SELECTOR) + MSTORE(64, iteration_count)

We currently duplicate the attack sequence because we do not know the exact counter value (iteration_count) at construction time, so i need to assume the value to be 32, and then updated with correct value later. Is there a way to avoid this duplication?

Something like this:

# user-facing
iteration_count = Bytecode.auto()        # implicit returns a Placeholder object
attack_code = MSTORE(0, iteration_count) + MSTORE(32, BALANCE_SELECTOR) + MSTORE(64, iteration_count)

# after template emitted
iteration_count_value = (gas_benchmark_value - prefix.gas - suffix.gas) // body.gas
attack_code.fill({ iteration_count: iteration_count_value })  # fills placeholder(s)

or

# one-liner style
attack_code = Bytecode.auto_fill_template(
    template = Op.MSTORE(0, auto()) + Op.MSTORE(32, BALANCE_SELECTOR) + Op.MSTORE(64, auto()),
    budget = gas_benchmark_value,
    counter_slot = 0,
)
# returns fully patched Bytecode

Impact

Not only benchmark test cases could benefit from this feature, currently there are a lot of test cases that uses a fixed gas cost, for example:

gas_measured_opcodes = [
(
"EXTCODESIZE",
CodeGasMeasure(
code=Op.EXTCODESIZE(Op.COINBASE),
overhead_cost=2,
extra_stack_items=1,
),
),
(
"EXTCODECOPY",
CodeGasMeasure(
code=Op.EXTCODECOPY(Op.COINBASE, 0, 0, 0),
overhead_cost=2 + 3 + 3 + 3,
),
),
(
"EXTCODEHASH",
CodeGasMeasure(
code=Op.EXTCODEHASH(Op.COINBASE),
overhead_cost=2,
extra_stack_items=1,
),
),
(
"BALANCE",
CodeGasMeasure(
code=Op.BALANCE(Op.COINBASE),
overhead_cost=2,
extra_stack_items=1,
),
),
(
"CALL",
CodeGasMeasure(
code=Op.CALL(0xFF, Op.COINBASE, 0, 0, 0, 0, 0),
overhead_cost=3 + 2 + 3 + 3 + 3 + 3 + 3,
extra_stack_items=1,
),
),
(
"CALLCODE",
CodeGasMeasure(
code=Op.CALLCODE(0xFF, Op.COINBASE, 0, 0, 0, 0, 0),
overhead_cost=3 + 2 + 3 + 3 + 3 + 3 + 3,
extra_stack_items=1,
),
),
(
"DELEGATECALL",
CodeGasMeasure(
code=Op.DELEGATECALL(0xFF, Op.COINBASE, 0, 0, 0, 0),
overhead_cost=3 + 2 + 3 + 3 + 3 + 3,
extra_stack_items=1,
),
),
(
"STATICCALL",
CodeGasMeasure(
code=Op.STATICCALL(0xFF, Op.COINBASE, 0, 0, 0, 0),
overhead_cost=3 + 2 + 3 + 3 + 3 + 3,
extra_stack_items=1,
),
),
]

overhead here are all hardcoded value, if the gas repricing in Glamsterdam is implemented, all these cases will break.

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions