diff --git a/.github/workflows/glamsterdam-write-prepayment.yaml b/.github/workflows/glamsterdam-write-prepayment.yaml new file mode 100644 index 00000000000..d7344c8c600 --- /dev/null +++ b/.github/workflows/glamsterdam-write-prepayment.yaml @@ -0,0 +1,25 @@ +name: Glamsterdam write-prepayment proof + +on: + push: + branches: + - "experiment/glamsterdam-write-prepayment" + workflow_dispatch: + +jobs: + proof: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup-uv + with: + python-version: "3.14" + - name: Fill targeted Osaka-to-Amsterdam proof + run: > + uv run fill + --skip-index + --clean + --from Osaka + --until Amsterdam + --output=.just/write-prepayment/fixtures + tests/amsterdam/eip8038_state_access_gas_cost_increase/test_write_prepayment_prototype.py diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 5c4a10809cc..93825da950e 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -103,6 +103,7 @@ settle_transaction_gas, ) from .vm.interpreter import TransactionOutput, process_top_level +from .write_prepayment import find_write_prepayments BASE_FEE_MAX_CHANGE_DENOMINATOR = Uint(8) ELASTICITY_MULTIPLIER = Uint(2) @@ -591,6 +592,10 @@ def check_transaction( for slot in access.slots: access_list_storage_keys.add((access.account, slot)) + write_prepaid_storage_keys = find_write_prepayments( + access_list_storage_keys + ) + authorizations: Tuple[Authorization, ...] = () if isinstance(tx, SetCodeTransaction): authorizations = tx.authorizations @@ -621,6 +626,7 @@ def check_transaction( calldata_floor=intrinsic.calldata_floor, access_list_addresses=access_list_addresses, access_list_storage_keys=access_list_storage_keys, + write_prepaid_storage_keys=write_prepaid_storage_keys, accounts_with_paid_writes=accounts_with_paid_writes, state=tx_state, blob_versioned_hashes=blob_versioned_hashes, @@ -767,6 +773,7 @@ def process_unchecked_system_transaction( calldata_floor=Uint(0), access_list_addresses=set(), access_list_storage_keys=set(), + write_prepaid_storage_keys=set(), # A system transaction charges no gas, so no write is paid for. accounts_with_paid_writes=set(), state=system_tx_state, diff --git a/src/ethereum/forks/amsterdam/transactions.py b/src/ethereum/forks/amsterdam/transactions.py index 693064688da..3dcc08a8ecf 100644 --- a/src/ethereum/forks/amsterdam/transactions.py +++ b/src/ethereum/forks/amsterdam/transactions.py @@ -35,6 +35,11 @@ TransactionTypeError, ) from .fork_types import Authorization, ExecutionGas, VersionedHash +from .write_prepayment import ( + STORAGE_WRITE_REPRICING_DELTA, + WRITE_PREPAYMENT_MARKER_COUNT, + find_write_prepayments, +) @final @@ -728,6 +733,7 @@ def calculate_intrinsic_cost( recipient_execution_gas += GasCosts.TX_VALUE_COST access_list_cost = Uint(0) + access_list_entries: set[tuple[Address, Bytes32]] = set() tokens_in_access_list = Uint(0) if has_access_list(tx): for access in tx.access_list: @@ -739,6 +745,23 @@ def calculate_intrinsic_cost( tokens_in_access_list += ( ulen(access.slots) * ACCESS_LIST_STORAGE_KEY_FLOOR_TOKENS ) + for slot in access.slots: + access_list_entries.add((access.account, slot)) + + # Experimental encoding: three ordinary access-list marker keys carry + # 6,000 gas of the 7,200 write-repricing delta. Charge the exact 1,200 + # remainder here so a complete voucher has paid the full delta before + # any frame starts executing. + write_prepayments = find_write_prepayments(access_list_entries) + marker_prepayment = GasCosts.TX_ACCESS_LIST_STORAGE_KEY * Uint( + WRITE_PREPAYMENT_MARKER_COUNT + ) + write_prepayment_remainder = ( + STORAGE_WRITE_REPRICING_DELTA - marker_prepayment + ) + write_prepayment_cost = write_prepayment_remainder * ulen( + write_prepayments + ) # Data token floor cost for access list bytes. access_list_cost += tokens_in_access_list * GasCosts.TX_DATA_TOKEN_FLOOR @@ -770,6 +793,7 @@ def calculate_intrinsic_cost( + init_code_gas + data_cost + access_list_cost + + write_prepayment_cost + auth_cost ), calldata_floor=ExecutionGas(data_floor_gas_cost), diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 3125187719d..263e40f19d3 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -139,6 +139,7 @@ class TransactionEnvironment: calldata_floor: Uint access_list_addresses: Set[Address] access_list_storage_keys: Set[Tuple[Address, Bytes32]] + write_prepaid_storage_keys: Set[Tuple[Address, Bytes32]] accounts_with_paid_writes: Set[Address] state: TransactionState blob_versioned_hashes: Tuple[VersionedHash, ...] diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index 432a3500baa..335735ffaf3 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -11,6 +11,7 @@ Implementations of the EVM storage related instructions. """ +from ethereum_types.bytes import Bytes32 from ethereum_types.numeric import Uint from ...fork_types import ExecutionGas, StateGas @@ -21,6 +22,7 @@ set_storage, set_transient_storage, ) +from ...write_prepayment import STORAGE_WRITE_REPRICING_DELTA from .. import Evm from ..exceptions import WriteInStaticContext from ..gas import ( @@ -34,6 +36,18 @@ from ..stack import pop, push +def _consume_write_prepayment(evm: Evm, key: Bytes32) -> bool: + """Consume one transaction-boundary write prepayment.""" + entry = (evm.current_target, key) + if entry not in evm.tx_env.write_prepaid_storage_keys: + return False + + # Consumption is transaction-wide, so a reverted or OOG child cannot + # reuse one intrinsic prepayment in another frame. + evm.tx_env.write_prepaid_storage_keys.remove(entry) + return True + + def sload(evm: Evm) -> None: """ Loads to the stack, the value corresponding to a certain key from the @@ -120,7 +134,10 @@ def sstore(evm: Evm) -> None: # Write cost: charged on the first change to the slot this transaction. if original_value == current_value and current_value != new_value: - gas_cost += GasCosts.STORAGE_WRITE + if _consume_write_prepayment(evm, key): + gas_cost += GasCosts.STORAGE_WRITE - STORAGE_WRITE_REPRICING_DELTA + else: + gas_cost += GasCosts.STORAGE_WRITE # Refund Counter Calculation if current_value != new_value: diff --git a/src/ethereum/forks/amsterdam/write_prepayment.py b/src/ethereum/forks/amsterdam/write_prepayment.py new file mode 100644 index 00000000000..a7120e27b3b --- /dev/null +++ b/src/ethereum/forks/amsterdam/write_prepayment.py @@ -0,0 +1,42 @@ +"""Experimental transaction-boundary storage-write prepayments.""" + +from typing import Iterable + +from ethereum_types.bytes import Bytes32 +from ethereum_types.numeric import Uint + +from ethereum.crypto.hash import keccak256 +from ethereum.state import Address + +from .fork_types import ExecutionGas + +WRITE_PREPAYMENT_DOMAIN = b"glamsterdam-write-prepayment-v1" +WRITE_PREPAYMENT_MARKER_COUNT = 3 + +# EIP-8038 raises the existing-slot write component from 2,800 to 10,000. +# A voucher moves only that 7,200 repricing delta to transaction scope. +STORAGE_WRITE_REPRICING_DELTA = ExecutionGas(Uint(7_200)) + + +def write_prepayment_markers( + address: Address, key: Bytes32 +) -> tuple[Bytes32, ...]: + """Return the provisional access-list markers for one storage write.""" + prefix = WRITE_PREPAYMENT_DOMAIN + bytes(address) + bytes(key) + return tuple( + keccak256(prefix + bytes([index])) + for index in range(WRITE_PREPAYMENT_MARKER_COUNT) + ) + + +def find_write_prepayments( + entries: Iterable[tuple[Address, Bytes32]], +) -> set[tuple[Address, Bytes32]]: + """Decode complete provisional vouchers from access-list entries.""" + declared = set(entries) + prepaid: set[tuple[Address, Bytes32]] = set() + for address, key in declared: + markers = write_prepayment_markers(address, key) + if all((address, marker) in declared for marker in markers): + prepaid.add((address, key)) + return prepaid diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_write_prepayment_prototype.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_write_prepayment_prototype.py new file mode 100644 index 00000000000..724bbc4c8ba --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_write_prepayment_prototype.py @@ -0,0 +1,429 @@ +""" +Executable four-assertion proof for fixed-gas call liveness across EIP-8038. + +Four independently named tests prove Osaka PASS, Amsterdam FAIL, Amsterdam +with a write prepayment PASS, and preservation of the 12,100 Amsterdam storage +resource charge. + +The experimental voucher is signaled by three domain-separated EIP-2930 +storage-key markers. Their 6,000 core intrinsic charge plus a 1,200 prototype +intrinsic surcharge pay the exact 7,200 repricing delta at the transaction +boundary. The child retains the historical 2,800 write component. + +Additional fixtures contain the proof mechanism: partial marker sets do not +activate it, an OOG attempt consumes the prepaid credit instead of making it +replayable, and ordinary Amsterdam writes retain their normal semantics. + +This marker encoding is a proof vehicle, not a proposed final wire format. +""" + +import pytest +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U64, U256, Uint +from execution_testing import ( + AccessList, + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + Bytecode, + Fork, + Op, + Transaction, +) + +from ethereum.crypto.hash import keccak256 +from ethereum.forks.amsterdam.transactions import ( + ACCESS_LIST_STORAGE_KEY_FLOOR_TOKENS, + AccessListTransaction, + calculate_intrinsic_cost, +) +from ethereum.forks.amsterdam.transactions import ( + Access as AmsterdamAccess, +) +from ethereum.forks.amsterdam.vm.gas import GasCosts +from ethereum.state import Address as StateAddress + +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +BEFORE_TS = 14_999 +AFTER_TS = 15_000 +VOUCHER_TS = 15_001 +WRITE_PREPAYMENT_DOMAIN = b"glamsterdam-write-prepayment-v1" +WRITE_PREPAYMENT_MARKER_COUNT = 3 + +pytestmark = pytest.mark.valid_at_transition_to("Amsterdam") + + +def _warm_existing_slot_write(fork: Fork) -> Bytecode: + """Return one warm nonzero-to-nonzero SSTORE with explicit metadata.""" + return Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=1, + new_value=2, + )(0, 2) + + +def _write_prepayment_markers( + address: Address | StateAddress, key: int +) -> list[Bytes32]: + """Mirror the experimental Amsterdam marker derivation.""" + key_bytes = Bytes32(key.to_bytes(32, "big")) + prefix = WRITE_PREPAYMENT_DOMAIN + bytes(address) + bytes(key_bytes) + return [ + keccak256(prefix + bytes([index])) + for index in range(WRITE_PREPAYMENT_MARKER_COUNT) + ] + + +def _fixed_gas_window(fork: Fork) -> tuple[int, int, int, int]: + """Return parent cost, Amsterdam cost, voucher cost, and fixed budget.""" + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + write = _warm_existing_slot_write(fork) + cost_before = write.execution_cost(before) + cost_after = write.execution_cost(after) + repricing_delta = cost_after - cost_before + voucher_frame_charge = cost_after - repricing_delta + fixed_child_gas = (cost_before + cost_after) // 2 + return cost_before, cost_after, voucher_frame_charge, fixed_child_gas + + +def _execute_fixed_gas_leg( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + *, + timestamp: int, + voucher: bool, + expected_value: int, +) -> None: + """Execute one parent/child pair under the shared immutable budget.""" + cost_before, cost_after, voucher_cost, fixed_child_gas = _fixed_gas_window( + fork + ) + assert cost_before <= fixed_child_gas < cost_after + assert voucher_cost == cost_before + + child = pre.deploy_contract(code=Op.SSTORE(0, 2), storage={0: 1}) + parent = pre.deploy_contract( + code=Op.POP(Op.CALL(gas=fixed_child_gas, address=child)) + ) + + storage_keys: list[int | Bytes32] = [0] + if voucher: + storage_keys.extend(_write_prepayment_markers(child, 0)) + + blocks = [ + Block( + timestamp=timestamp, + txs=[ + Transaction( + to=parent, + sender=pre.fund_eoa(), + access_list=[ + AccessList(address=child, storage_keys=storage_keys) + ], + ) + ], + ) + ] + post = {child: Account(storage={0: expected_value})} + blockchain_test(pre=pre, blocks=blocks, post=post) + + +def test_osaka_pass( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """OSAKA_PASS: the fixed-gas child changes the existing nonzero slot.""" + _execute_fixed_gas_leg( + blockchain_test, + pre, + fork, + timestamp=BEFORE_TS, + voucher=False, + expected_value=2, + ) + + +def test_amsterdam_fail( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """AMSTERDAM_FAIL: identical child execution runs out of gas.""" + _execute_fixed_gas_leg( + blockchain_test, + pre, + fork, + timestamp=AFTER_TS, + voucher=False, + expected_value=1, + ) + + +def test_amsterdam_voucher_pass( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """AMSTERDAM_VOUCHER_PASS: prepaid delta restores child liveness.""" + _execute_fixed_gas_leg( + blockchain_test, + pre, + fork, + timestamp=VOUCHER_TS, + voucher=True, + expected_value=2, + ) + + +def test_amsterdam_total_charge_preserved( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """AMSTERDAM_TOTAL_CHARGE_PRESERVED: both paths charge 12,100.""" + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + bare_write = Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=1, + new_value=2, + ) + + warm_access = Op.SLOAD(key_warm=True).gas_cost(after) + cold_access = Op.SLOAD(key_warm=False).gas_cost(after) + legacy_write = bare_write.execution_cost(before) - warm_access + amsterdam_write = bare_write.execution_cost(after) - warm_access + access_list_prepayment = cold_access - warm_access + repricing_delta = amsterdam_write - legacy_write + marker_prepayment = access_list_prepayment * WRITE_PREPAYMENT_MARKER_COUNT + prototype_remainder = repricing_delta - marker_prepayment + + ordinary_amsterdam = cold_access + amsterdam_write + prepaid_amsterdam = ( + access_list_prepayment + + marker_prepayment + + prototype_remainder + + warm_access + + legacy_write + ) + + assert access_list_prepayment == 2_000 + assert repricing_delta == 7_200 + assert marker_prepayment == 6_000 + assert prototype_remainder == 1_200 + assert warm_access == 100 + assert legacy_write == 2_800 + assert ordinary_amsterdam == prepaid_amsterdam == 12_100 + + # Bind the equation to the implemented transaction-boundary charge. The + # provisional marker bytes have ordinary EIP-7981 encoding overhead; after + # removing only that overhead, the actual intrinsic delta is exactly 7,200. + target = StateAddress(b"\x11" * 20) + sender = StateAddress(b"\x22" * 20) + key = Bytes32(b"\x00" * 32) + markers = tuple(_write_prepayment_markers(target, 0)) + + def intrinsic_for(slots: tuple[Bytes32, ...]) -> int: + tx = AccessListTransaction( + chain_id=U64(1), + nonce=U256(0), + gas_price=Uint(1), + gas=Uint(1_000_000), + to=target, + value=U256(0), + data=Bytes(b""), + access_list=(AmsterdamAccess(account=target, slots=slots),), + y_parity=U256(0), + r=U256(0), + s=U256(0), + ) + return int(calculate_intrinsic_cost(tx, sender).execution) + + ordinary_intrinsic = intrinsic_for((key,)) + voucher_intrinsic = intrinsic_for((key, *markers)) + marker_encoding_overhead = int( + Uint(WRITE_PREPAYMENT_MARKER_COUNT) + * ACCESS_LIST_STORAGE_KEY_FLOOR_TOKENS + * GasCosts.TX_DATA_TOKEN_FLOOR + ) + assert ( + voucher_intrinsic - ordinary_intrinsic - marker_encoding_overhead + == repricing_delta + == 7_200 + ) + + _, cost_after, voucher_cost, _ = _fixed_gas_window(fork) + ordinary_child = pre.deploy_contract(code=Op.SSTORE(0, 2), storage={0: 1}) + voucher_child = pre.deploy_contract(code=Op.SSTORE(0, 2), storage={0: 1}) + ordinary_parent = pre.deploy_contract( + code=Op.POP(Op.CALL(gas=cost_after, address=ordinary_child)) + ) + voucher_parent = pre.deploy_contract( + code=Op.POP(Op.CALL(gas=voucher_cost, address=voucher_child)) + ) + voucher_keys = [0, *_write_prepayment_markers(voucher_child, 0)] + + blockchain_test( + pre=pre, + blocks=[ + Block( + timestamp=AFTER_TS, + txs=[ + Transaction( + to=ordinary_parent, + sender=pre.fund_eoa(), + access_list=[ + AccessList( + address=ordinary_child, + storage_keys=[0], + ) + ], + ), + Transaction( + to=voucher_parent, + sender=pre.fund_eoa(), + access_list=[ + AccessList( + address=voucher_child, + storage_keys=voucher_keys, + ) + ], + ), + ], + ) + ], + post={ + ordinary_child: Account(storage={0: 2}), + voucher_child: Account(storage={0: 2}), + }, + ) + + +def test_partial_write_prepayment_does_not_discount( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """Two of the three markers are insufficient to rescue the fixed call.""" + _, cost_after, _, fixed_child_gas = _fixed_gas_window(fork) + assert fixed_child_gas < cost_after + + child = pre.deploy_contract(code=Op.SSTORE(0, 2), storage={0: 1}) + parent = pre.deploy_contract( + code=Op.POP(Op.CALL(gas=fixed_child_gas, address=child)) + ) + markers = _write_prepayment_markers(child, 0) + + blocks = [ + Block( + timestamp=AFTER_TS, + txs=[ + Transaction( + to=parent, + sender=pre.fund_eoa(), + access_list=[ + AccessList( + address=child, + storage_keys=[0, *markers[:2]], + ) + ], + ) + ], + ) + ] + + post = {child: Account(storage={0: 1})} + blockchain_test(pre=pre, blocks=blocks, post=post) + + +def test_write_prepayment_is_not_replayable_after_oog( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + An OOG attempt burns its prepaid write credit transaction-wide. + + The first call has one gas less than the discounted frame requires. The + SSTORE recognizes and consumes the marker set, then fails its gas charge. + A second call in the same transaction has enough gas for the discounted + write but not the ordinary Amsterdam write. It must still fail; otherwise + one intrinsic prepayment could subsidize arbitrarily many retrying frames. + """ + _, cost_after, voucher_frame_charge, fixed_child_gas = _fixed_gas_window( + fork + ) + assert voucher_frame_charge > 2_301 + first_attempt_gas = voucher_frame_charge - 1 + assert first_attempt_gas < voucher_frame_charge <= fixed_child_gas + assert fixed_child_gas < cost_after + + child = pre.deploy_contract(code=Op.SSTORE(0, 2), storage={0: 1}) + parent = pre.deploy_contract( + code=( + Op.POP(Op.CALL(gas=first_attempt_gas, address=child)) + + Op.POP(Op.CALL(gas=fixed_child_gas, address=child)) + ) + ) + voucher_keys = [0, *_write_prepayment_markers(child, 0)] + + blocks = [ + Block( + timestamp=AFTER_TS, + txs=[ + Transaction( + to=parent, + sender=pre.fund_eoa(), + access_list=[ + AccessList(address=child, storage_keys=voucher_keys) + ], + ) + ], + ) + ] + + post = {child: Account(storage={0: 1})} + blockchain_test(pre=pre, blocks=blocks, post=post) + + +def test_ordinary_amsterdam_sstore_remains_unchanged( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Verify an adequately funded Amsterdam write still works without markers. + """ + _, cost_after, _, _ = _fixed_gas_window(fork) + + child = pre.deploy_contract(code=Op.SSTORE(0, 2), storage={0: 1}) + parent = pre.deploy_contract( + code=Op.POP(Op.CALL(gas=cost_after + 10_000, address=child)) + ) + + blocks = [ + Block( + timestamp=AFTER_TS, + txs=[ + Transaction( + to=parent, + sender=pre.fund_eoa(), + access_list=[AccessList(address=child, storage_keys=[0])], + ) + ], + ) + ] + + post = {child: Account(storage={0: 2})} + blockchain_test(pre=pre, blocks=blocks, post=post)