Skip to content

test_recursive_contract_creation_and_selfdestruct()

Documentation for tests/cancun/eip6780_selfdestruct/test_selfdestruct.py::test_recursive_contract_creation_and_selfdestruct@ffe48572.

Generate fixtures for these test cases for Bogota with:

fill -v tests/cancun/eip6780_selfdestruct/test_selfdestruct.py::test_recursive_contract_creation_and_selfdestruct --fork Bogota

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.

Source code in tests/cancun/eip6780_selfdestruct/test_selfdestruct.py
@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)

Parametrized Test Cases

This test generates 8 parametrized test cases across 6 forks.