Skip to content

Commit ae01f19

Browse files
committed
feat(spec-specs, tests): EIP-8037 - SELFDESTRUCT same-tx refunds state gas at end of tx (#2707)
1 parent 8c01923 commit ae01f19

2 files changed

Lines changed: 312 additions & 1 deletion

File tree

src/ethereum/forks/amsterdam/fork.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,13 @@
9898
from .vm.gas import (
9999
BLOB_SCHEDULE_MAX,
100100
GAS_PER_BLOB,
101+
STATE_BYTES_PER_NEW_ACCOUNT,
102+
STATE_BYTES_PER_STORAGE_SET,
101103
calculate_blob_gas_price,
102104
calculate_data_fee,
103105
calculate_excess_blob_gas,
104106
calculate_total_blob_gas,
107+
state_gas_per_byte,
105108
)
106109
from .vm.interpreter import MessageCallOutput, process_message_call
107110

@@ -1053,6 +1056,34 @@ def process_transaction(
10531056
if tx_output.error is not None:
10541057
tx_output.state_gas_left += tx_output.state_gas_used
10551058
tx_output.state_gas_used = Uint(0)
1059+
else:
1060+
# Refund state gas for accounts created and destroyed in the
1061+
# same tx (EIP-6780). Covers account, storage, and code.
1062+
cost_per_state_byte = state_gas_per_byte(block_env.block_gas_limit)
1063+
for address in tx_output.accounts_to_delete:
1064+
if address in tx_state.created_accounts:
1065+
selfdestruct_refund = (
1066+
STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte
1067+
)
1068+
storage = tx_state.storage_writes.get(address, {})
1069+
created_slots = sum(1 for v in storage.values() if v != 0)
1070+
selfdestruct_refund += (
1071+
Uint(created_slots)
1072+
* STATE_BYTES_PER_STORAGE_SET
1073+
* cost_per_state_byte
1074+
)
1075+
# EIP-6780 defers account/storage/code removal to
1076+
# tx-end, so `account.code_hash` still points at the
1077+
# deployed code here and `get_code` returns it
1078+
# pre-deletion.
1079+
account = get_account(tx_state, address)
1080+
code = get_code(tx_state, account.code_hash)
1081+
selfdestruct_refund += Uint(len(code)) * cost_per_state_byte
1082+
selfdestruct_refund = min(
1083+
selfdestruct_refund, tx_output.state_gas_used
1084+
)
1085+
tx_output.state_gas_left += selfdestruct_refund
1086+
tx_output.state_gas_used -= selfdestruct_refund
10561087

10571088
tx_gas_used_before_refund = (
10581089
tx.gas - tx_output.gas_left - tx_output.state_gas_left

tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_selfdestruct.py

Lines changed: 281 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,19 @@
1616
Alloc,
1717
Block,
1818
BlockchainTestFiller,
19+
Bytecode,
1920
Environment,
2021
Fork,
22+
Header,
23+
Initcode,
2124
Op,
2225
StateTestFiller,
2326
Storage,
2427
Transaction,
28+
compute_create_address,
2529
)
2630

27-
from .spec import ref_spec_8037
31+
from .spec import init_code_at_high_bytes, ref_spec_8037
2832

2933
REFERENCE_SPEC_GIT_PATH = ref_spec_8037.git_path
3034
REFERENCE_SPEC_VERSION = ref_spec_8037.version
@@ -248,3 +252,279 @@ def test_selfdestruct_new_beneficiary_header_gas_used(
248252
],
249253
post={caller: Account(storage=storage)},
250254
)
255+
256+
257+
@pytest.mark.parametrize(
258+
"num_slots",
259+
[
260+
pytest.param(0, id="no_storage"),
261+
pytest.param(1, id="one_slot"),
262+
pytest.param(5, id="five_slots"),
263+
],
264+
)
265+
@pytest.mark.with_all_create_opcodes()
266+
@pytest.mark.valid_from("EIP8037")
267+
def test_create_selfdestruct_refunds_account_and_storage(
268+
blockchain_test: BlockchainTestFiller,
269+
pre: Alloc,
270+
fork: Fork,
271+
create_opcode: Op,
272+
num_slots: int,
273+
) -> None:
274+
"""
275+
Verify same tx CREATE+SELFDESTRUCT refunds account and storage.
276+
277+
Factory CREATE/CREATE2 initcode does N cold SSTOREs then
278+
SELFDESTRUCTs. Refund covers `GAS_NEW_ACCOUNT` plus each
279+
created slot's state gas. Under OLD behavior the state charges
280+
remain in `block_state_gas_used`. Under NEW they are refunded.
281+
"""
282+
gas_limit_cap = fork.transaction_gas_limit_cap()
283+
assert gas_limit_cap is not None
284+
new_account_state_gas = fork.gas_costs().GAS_NEW_ACCOUNT
285+
sstore_state_gas = fork.sstore_state_gas()
286+
intrinsic_gas = fork.transaction_intrinsic_cost_calculator()()
287+
288+
init_code = Bytecode()
289+
for i in range(num_slots):
290+
init_code += Op.SSTORE.with_metadata(
291+
key_warm=False,
292+
original_value=0,
293+
current_value=0,
294+
new_value=1,
295+
)(i, 1)
296+
init_code += Op.SELFDESTRUCT.with_metadata(address_warm=True)(Op.ADDRESS)
297+
mstore_value, size = init_code_at_high_bytes(init_code)
298+
299+
# Metadata so `.gas_cost(fork)` matches runtime charges.
300+
mstore = Op.MSTORE.with_metadata(new_memory_size=32, old_memory_size=0)(
301+
0, mstore_value
302+
)
303+
create_metadata = create_opcode.with_metadata(init_code_size=size)
304+
create_call = (
305+
create_metadata(value=0, offset=0, size=size, salt=0)
306+
if create_opcode == Op.CREATE2
307+
else create_metadata(value=0, offset=0, size=size)
308+
)
309+
factory_code = mstore + Op.POP(create_call)
310+
factory = pre.deploy_contract(code=factory_code)
311+
312+
total_state_refund = new_account_state_gas + num_slots * sstore_state_gas
313+
# Subtract the state portion so tx_regular matches the header.
314+
tx_regular = (
315+
intrinsic_gas
316+
+ factory_code.gas_cost(fork)
317+
+ init_code.gas_cost(fork)
318+
- total_state_refund
319+
)
320+
321+
tx = Transaction(
322+
to=factory,
323+
gas_limit=gas_limit_cap + total_state_refund,
324+
sender=pre.fund_eoa(),
325+
)
326+
327+
blockchain_test(
328+
pre=pre,
329+
blocks=[Block(txs=[tx], header_verify=Header(gas_used=tx_regular))],
330+
post={},
331+
)
332+
333+
334+
@pytest.mark.parametrize(
335+
"beneficiary_type,code_size",
336+
[
337+
pytest.param("self", 2, id="self_tiny"),
338+
pytest.param("self", 100, id="self_medium"),
339+
pytest.param("external", 100, id="external_medium"),
340+
],
341+
)
342+
@pytest.mark.valid_from("EIP8037")
343+
def test_create_selfdestruct_refunds_code_deposit_state_gas(
344+
blockchain_test: BlockchainTestFiller,
345+
pre: Alloc,
346+
fork: Fork,
347+
code_size: int,
348+
beneficiary_type: str,
349+
) -> None:
350+
"""
351+
Verify same tx CREATE+SELFDESTRUCT refunds code deposit state gas.
352+
353+
Factory CREATEs a contract deploying `code_size` bytes of code
354+
then CALLs it to trigger SELFDESTRUCT. Refund is account plus
355+
`code_size * cost_per_state_byte`. `external` beneficiary tests
356+
that the refund applies to the created account, not the
357+
destination of the ETH transfer.
358+
"""
359+
assert code_size >= 2
360+
gas_limit_cap = fork.transaction_gas_limit_cap()
361+
assert gas_limit_cap is not None
362+
new_account_state_gas = fork.gas_costs().GAS_NEW_ACCOUNT
363+
code_deposit_state_gas = fork.code_deposit_state_gas(code_size=code_size)
364+
365+
if beneficiary_type == "self":
366+
selfdestruct = Op.SELFDESTRUCT(Op.ADDRESS)
367+
else:
368+
beneficiary = pre.deploy_contract(code=Op.STOP)
369+
selfdestruct = Op.SELFDESTRUCT(beneficiary)
370+
sd_len = len(bytes(selfdestruct))
371+
assert code_size >= sd_len
372+
deployed = bytes(selfdestruct) + b"\x00" * (code_size - sd_len)
373+
initcode = Initcode(deploy_code=deployed)
374+
initcode_len = len(initcode)
375+
376+
# Nest CREATE directly as the address argument to CALL so the
377+
# deployed contract's address flows via the stack, avoiding a
378+
# magic memory slot for address storage and an arbitrary gas
379+
# budget.
380+
factory_code = Op.CALLDATACOPY(
381+
0,
382+
0,
383+
Op.CALLDATASIZE,
384+
data_size=initcode_len,
385+
new_memory_size=initcode_len,
386+
) + Op.POP(
387+
Op.CALL(
388+
gas=Op.GAS,
389+
address=Op.CREATE(
390+
value=0,
391+
offset=0,
392+
size=Op.CALLDATASIZE,
393+
init_code_size=initcode_len,
394+
),
395+
)
396+
)
397+
factory = pre.deploy_contract(code=factory_code)
398+
created_address = compute_create_address(address=factory, nonce=1)
399+
400+
total_state_refund = new_account_state_gas + code_deposit_state_gas
401+
tx = Transaction(
402+
to=factory,
403+
data=bytes(initcode),
404+
gas_limit=gas_limit_cap + total_state_refund,
405+
sender=pre.fund_eoa(),
406+
)
407+
408+
blockchain_test(
409+
pre=pre,
410+
blocks=[Block(txs=[tx])],
411+
post={created_address: Account.NONEXISTENT},
412+
)
413+
414+
415+
@pytest.mark.valid_from("EIP8037")
416+
def test_create_selfdestruct_no_double_refund_with_sstore_restoration(
417+
blockchain_test: BlockchainTestFiller,
418+
pre: Alloc,
419+
fork: Fork,
420+
) -> None:
421+
"""
422+
Verify SSTORE restoration and SELFDESTRUCT refunds do not stack.
423+
424+
Initcode does SSTORE(0, 1) then SSTORE(0, 0) then SELFDESTRUCT.
425+
The 0 to x to 0 restoration refunds the slot inline. The end of
426+
tx selfdestruct refund scans `storage_writes[B]` and only counts
427+
non zero final values, so the restored slot is excluded and the
428+
end of tx refund is account only.
429+
"""
430+
gas_limit_cap = fork.transaction_gas_limit_cap()
431+
assert gas_limit_cap is not None
432+
new_account_state_gas = fork.gas_costs().GAS_NEW_ACCOUNT
433+
sstore_state_gas = fork.sstore_state_gas()
434+
intrinsic_gas = fork.transaction_intrinsic_cost_calculator()()
435+
436+
init_code = (
437+
Op.SSTORE.with_metadata(
438+
key_warm=False,
439+
original_value=0,
440+
current_value=0,
441+
new_value=1,
442+
)(0, 1)
443+
+ Op.SSTORE.with_metadata(
444+
key_warm=True,
445+
original_value=0,
446+
current_value=1,
447+
new_value=0,
448+
)(0, 0)
449+
+ Op.SELFDESTRUCT.with_metadata(address_warm=True)(Op.ADDRESS)
450+
)
451+
mstore_value, size = init_code_at_high_bytes(init_code)
452+
453+
mstore = Op.MSTORE.with_metadata(new_memory_size=32, old_memory_size=0)(
454+
0, mstore_value
455+
)
456+
create_call = Op.CREATE.with_metadata(init_code_size=size)(0, 0, size)
457+
factory_code = mstore + Op.POP(create_call)
458+
factory = pre.deploy_contract(code=factory_code)
459+
460+
# Subtract both state charges (CREATE account + cold SSTORE) to
461+
# isolate the regular total.
462+
tx_regular = (
463+
intrinsic_gas
464+
+ factory_code.gas_cost(fork)
465+
+ init_code.gas_cost(fork)
466+
- new_account_state_gas
467+
- sstore_state_gas
468+
)
469+
470+
tx = Transaction(
471+
to=factory,
472+
gas_limit=gas_limit_cap + new_account_state_gas + sstore_state_gas,
473+
sender=pre.fund_eoa(),
474+
)
475+
476+
blockchain_test(
477+
pre=pre,
478+
blocks=[Block(txs=[tx], header_verify=Header(gas_used=tx_regular))],
479+
post={},
480+
)
481+
482+
483+
@pytest.mark.valid_from("EIP8037")
484+
def test_selfdestruct_pre_existing_account_no_refund(
485+
blockchain_test: BlockchainTestFiller,
486+
pre: Alloc,
487+
fork: Fork,
488+
) -> None:
489+
"""
490+
Verify SELFDESTRUCT of a pre-existing account earns no refund.
491+
492+
The same-tx-create guard (`address in tx_state.created_accounts`)
493+
is load-bearing: without it, destroying any account would leak
494+
state gas back into the reservoir. A contract deployed in `pre`
495+
is destroyed by the tx; `accounts_to_delete` contains it but
496+
`created_accounts` does not, so no refund is applied. The block
497+
header `gas_used` reflects the full regular-gas tx cost (no
498+
state-gas refund offset).
499+
"""
500+
gas_limit_cap = fork.transaction_gas_limit_cap()
501+
assert gas_limit_cap is not None
502+
intrinsic_gas = fork.transaction_intrinsic_cost_calculator()()
503+
504+
# Victim deployed in `pre` (NOT same-tx-created). SELFDESTRUCTs
505+
# to self so no new-account state gas is charged to the tx.
506+
victim_code = Op.SELFDESTRUCT.with_metadata(address_warm=True)(Op.ADDRESS)
507+
victim = pre.deploy_contract(code=victim_code)
508+
509+
caller_code = Op.POP(Op.CALL(gas=Op.GAS, address=victim))
510+
caller = pre.deploy_contract(code=caller_code)
511+
512+
# No refund offset: both caller_code and victim_code are pure
513+
# regular gas (SELFDESTRUCT to self, no value-to-new-account).
514+
tx_regular = (
515+
intrinsic_gas + caller_code.gas_cost(fork) + victim_code.gas_cost(fork)
516+
)
517+
518+
tx = Transaction(
519+
to=caller,
520+
gas_limit=gas_limit_cap,
521+
sender=pre.fund_eoa(),
522+
)
523+
524+
# Per EIP-6780, SELFDESTRUCT on a not-same-tx-created account
525+
# does not delete it — the account still exists after the tx.
526+
blockchain_test(
527+
pre=pre,
528+
blocks=[Block(txs=[tx], header_verify=Header(gas_used=tx_regular))],
529+
post={victim: Account(code=victim_code)},
530+
)

0 commit comments

Comments
 (0)