@pytest.mark.parametrize("recursion_depth", [2, 3])
@pytest.mark.parametrize("selfdestruct_on_unwind", [True, False])
@pytest.mark.parametrize("selfdestruct_contract_initial_balance", [0, 100_000])
@pytest.mark.valid_from("Shanghai")
def test_recursive_contract_creation_and_selfdestruct(
state_test: StateTestFiller,
pre: Alloc,
sender: EOA,
fork: Fork,
recursion_depth: int,
selfdestruct_on_unwind: bool,
selfdestruct_contract_initial_balance: int,
) -> None:
"""
Test recursive contract creation with self-destruct.
Each contract creates another, forming a chain. Then self-destruct is
triggered either:
- selfdestruct_on_unwind=True: Each contract self-destructs as the call
stack unwinds
- selfdestruct_on_unwind=False: Only the deepest contract self-destructs
All contracts are created in the same transaction, so any that
self-destruct should be deleted.
"""
entry_code_storage = Storage()
sendall_recipient = pre.deploy_contract(
code=Op.SSTORE(0, 0),
storage={0: 1},
)
# We'll create a chain of contracts where each creates the next one
# using CREATE. Each contract's code will:
# 1. Check depth (from calldata)
# 2. If depth > 0: copy child initcode, create child, call child with
# depth-1
# 3. If selfdestruct_on_unwind: selfdestruct after child call returns
# 4. If depth == 0: selfdestruct immediately
# To make this work, we pre-deploy initcodes for each level
# Level 0 (deepest): just selfdestructs
level_initcodes: List[Bytecode] = []
level_codes: List[Bytecode] = []
# Build from deepest to shallowest
# Level 0 (deepest): always self-destructs
level_0_code = Op.SSTORE(0, Op.ADD(Op.SLOAD(0), 1)) + Op.SELFDESTRUCT(
sendall_recipient
)
level_codes.append(level_0_code)
level_initcodes.append(Initcode(deploy_code=level_0_code))
# Higher levels: create child, call it, optionally self-destruct
for level in range(1, recursion_depth):
child_initcode = level_initcodes[level - 1]
child_initcode_deployed = pre.deploy_contract(child_initcode)
child_len = len(child_initcode)
if selfdestruct_on_unwind:
level_code = (
Op.SSTORE(0, Op.ADD(Op.SLOAD(0), 1))
+ Op.EXTCODECOPY(child_initcode_deployed, 0, 0, child_len)
+ Op.SSTORE(1, Op.CREATE(value=0, offset=0, size=child_len))
+ Op.CALL(Op.GASLIMIT, Op.SLOAD(1), 0, 0, 0, 0, 0)
+ Op.SELFDESTRUCT(sendall_recipient)
)
else:
level_code = (
Op.SSTORE(0, Op.ADD(Op.SLOAD(0), 1))
+ Op.EXTCODECOPY(child_initcode_deployed, 0, 0, child_len)
+ Op.SSTORE(1, Op.CREATE(value=0, offset=0, size=child_len))
+ Op.CALL(Op.GASLIMIT, Op.SLOAD(1), 0, 0, 0, 0, 0)
+ Op.STOP
)
level_codes.append(level_code)
level_initcodes.append(Initcode(deploy_code=level_code))
# Top level initcode (the one we'll create from entry code)
top_initcode = level_initcodes[-1]
top_initcode_address = pre.deploy_contract(top_initcode)
entry_code_address = compute_create_address(address=sender, nonce=0)
# Calculate all contract addresses
contract_addresses: List[Address] = []
for level in range(recursion_depth - 1, -1, -1):
if level == recursion_depth - 1:
addr = compute_create_address(
address=entry_code_address,
nonce=1,
initcode=level_initcodes[level],
opcode=Op.CREATE,
)
else:
addr = compute_create_address(
address=contract_addresses[-1],
nonce=1,
initcode=level_initcodes[level],
opcode=Op.CREATE,
)
contract_addresses.append(addr)
if selfdestruct_contract_initial_balance > 0:
pre.fund_address(addr, selfdestruct_contract_initial_balance)
# Entry code
entry_code = Op.EXTCODECOPY(
top_initcode_address,
0,
0,
len(top_initcode),
)
entry_code += Op.SSTORE(
entry_code_storage.store_next(contract_addresses[0]),
Op.CREATE(value=0, offset=0, size=len(top_initcode)),
)
entry_code += Op.SSTORE(
entry_code_storage.store_next(1),
Op.CALL(Op.GASLIMIT, contract_addresses[0], 0, 0, 0, 0, 0),
)
entry_code += Op.RETURN(32, 1)
tx = Transaction(
data=entry_code,
sender=sender,
to=None,
)
post: Dict[Address, Account] = {
entry_code_address: Account(storage=entry_code_storage),
}
if selfdestruct_on_unwind:
# All contracts self-destruct
total_sendall = selfdestruct_contract_initial_balance * recursion_depth
for addr in contract_addresses:
post[addr] = Account.NONEXISTENT # type: ignore
else:
# Only the deepest contract (last in list) self-destructs
total_sendall = selfdestruct_contract_initial_balance
for i, addr in enumerate(contract_addresses):
if i == len(contract_addresses) - 1:
# Deepest - destroyed
post[addr] = Account.NONEXISTENT # type: ignore
else:
# Survives with storage: slot 0 = call count, slot 1 = child
# Retains its initial balance since it didn't self-destruct
child_addr = contract_addresses[i + 1]
post[addr] = Account(
storage={0: 1, 1: child_addr},
balance=selfdestruct_contract_initial_balance,
)
post[sendall_recipient] = Account(
balance=total_sendall,
storage={0: 1},
)
if fork.is_eip_enabled(7708):
# CREATE/CALL all use value=0, so the only Transfer logs come from
# each SELFDESTRUCT that runs. On unwind every contract SDs, starting
# from the deepest; otherwise only the deepest SDs.
expected_logs = []
if selfdestruct_contract_initial_balance > 0:
sd_sources = (
list(reversed(contract_addresses))
if selfdestruct_on_unwind
else [contract_addresses[-1]]
)
for addr in sd_sources:
expected_logs.append(
transfer_log(
addr,
sendall_recipient,
selfdestruct_contract_initial_balance,
)
)
tx.expected_receipt = TransactionReceipt(logs=expected_logs)
state_test(pre=pre, post=post, tx=tx)