Skip to content

test_recipient_charge_oog_rolls_back_delegations()

Documentation for tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py::test_recipient_charge_oog_rolls_back_delegations@26332146.

Generate fixtures for these test cases for Amsterdam with:

fill -v tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py::test_recipient_charge_oog_rolls_back_delegations --fork Amsterdam

A recipient top-frame charge running out of gas rolls back the already-applied delegations, because it shares the preparation snapshot with set_delegation.

Two valid authorizations on third-party authorities are paid in full, then the recipient's own top-frame charge is starved by one gas:

  • new_account: value moves to an EIP-161-empty recipient, whose NEW_ACCOUNT state charge runs out.
  • delegation_access: the recipient is a pre-existing delegation whose top-frame COLD_ACCOUNT_ACCESS charge runs out.

The recipient charge is part of the top-frame preparation, so its out-of-gas unwinds the whole preparation: both authorities return to their pre-transaction state. The transaction is still included, the receipt shows the full gas_limit consumed, and the recipient itself is unchanged.

The recipient and both authorities were accessed before the halt, so per EIP-7928 all three must still appear in the block access list, with no recorded changes. The recipient's delegation target is only ever loaded by the resolution the starved charge pays for, so it must be absent from the list on the out-of-gas side and present (with no changes) on the succeeding side.

The succeeds control restores the one starved gas: the recipient charge is covered exactly, the dispatch completes (the recipient runs no code of its own), and the delegations -- and any value moved -- stick, pinning the off-by-one boundary from above.

Source code in tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
@pytest.mark.parametrize(
    "succeeds",
    [
        pytest.param(False, id="fails"),
        pytest.param(True, id="succeeds"),
    ],
)
@pytest.mark.parametrize(
    "recipient_charge", ["new_account", "delegation_access"]
)
def test_recipient_charge_oog_rolls_back_delegations(
    fork: Fork,
    pre: Alloc,
    state_test: StateTestFiller,
    recipient_charge: str,
    succeeds: bool,
) -> None:
    """
    A recipient top-frame charge running out of gas rolls back the
    already-applied delegations, because it shares the preparation
    snapshot with ``set_delegation``.

    Two valid authorizations on third-party authorities are paid in
    full, then the recipient's own top-frame charge is starved by one
    gas:

    - ``new_account``: value moves to an EIP-161-empty recipient, whose
      ``NEW_ACCOUNT`` state charge runs out.
    - ``delegation_access``: the recipient is a pre-existing delegation
      whose top-frame ``COLD_ACCOUNT_ACCESS`` charge runs out.

    The recipient charge is part of the top-frame preparation, so its
    out-of-gas unwinds the whole preparation: both authorities return to
    their pre-transaction state. The transaction is still included, the
    receipt shows the full ``gas_limit`` consumed, and the recipient
    itself is unchanged.

    The recipient and both authorities were accessed before the halt,
    so per EIP-7928 all three must still appear in the block access
    list, with no recorded changes. The recipient's delegation target
    is only ever loaded by the resolution the starved charge pays for,
    so it must be absent from the list on the out-of-gas side and
    present (with no changes) on the succeeding side.

    The ``succeeds`` control restores the one starved gas: the
    recipient charge is covered exactly, the dispatch completes (the
    recipient runs no code of its own), and the delegations -- and any
    value moved -- stick, pinning the off-by-one boundary from above.
    """
    gas_costs = fork.gas_costs()
    sender = pre.fund_eoa()

    auth_a = build_authorization(pre, AuthorizationAction.CREATES_ACCOUNT)
    auth_b = build_authorization(pre, AuthorizationAction.SETS_NEW_DELEGATION)
    authorization_list = [auth_a.authorization, auth_b.authorization]
    auth_charges = _auth_top_frame_charges(fork, authorization_list)

    recipient_bal = BalAccountExpectation.empty()
    delegation_target_bal: dict[Address, BalAccountExpectation | None] = {}
    if recipient_charge == "new_account":
        recipient = pre.fund_eoa(amount=0)
        value = 1
        recipient_type = RecipientType.EMPTY_ACCOUNT
        recipient_charge_gas = gas_costs.NEW_ACCOUNT
        if succeeds:
            expected_recipient: Account | None = Account(balance=value)
            recipient_bal = BalAccountExpectation(
                balance_changes=[
                    BalBalanceChange(block_access_index=1, post_balance=value)
                ]
            )
        else:
            expected_recipient = None
    else:  # delegation_access
        delegated_to = pre.deploy_contract(code=Op.STOP)
        recipient = pre.fund_eoa(
            amount=EOA_INITIAL_BALANCE, delegation=delegated_to
        )
        value = 0
        recipient_type = RecipientType.DELEGATION_7702
        recipient_charge_gas = gas_costs.COLD_ACCOUNT_ACCESS
        expected_recipient = Account(
            nonce=1,
            balance=EOA_INITIAL_BALANCE,
            code=Spec7702.delegation_designation(delegated_to),
        )
        # The delegation target is only loaded by the resolution access
        # the starved charge pays for: read (unchanged) on success,
        # never accessed -- so absent from the block access list -- when
        # the charge runs out.
        delegation_target_bal = {
            delegated_to: BalAccountExpectation.empty() if succeeds else None
        }

    intrinsic_execution = _intrinsic_execution(
        fork,
        authorization_list,
        recipient_type=recipient_type,
        sends_value=bool(value),
    )

    # Both authorizations apply, then the recipient's top-frame charge
    # is starved by one gas -- or, with ``succeeds``, covered exactly.
    # The charge shares the preparation snapshot, so its out-of-gas
    # rolls the applied delegations back.
    gas_limit = intrinsic_execution + auth_charges + recipient_charge_gas
    if not succeeds:
        gas_limit -= 1

    tx = Transaction(
        sender=sender,
        to=recipient,
        value=value,
        authorization_list=authorization_list,
        gas_limit=gas_limit,
        expected_receipt=TransactionReceipt(
            cumulative_gas_used=gas_limit,
        ),
    )

    if succeeds:
        post = {
            auth_a.authority: auth_a.applied_account,
            auth_b.authority: auth_b.applied_account,
            recipient: expected_recipient,
        }
        expected_block_access_list = BlockAccessListExpectation(
            account_expectations={
                recipient: recipient_bal,
                auth_a.authority: _applied_delegation_bal(auth_a),
                auth_b.authority: _applied_delegation_bal(auth_b),
                **delegation_target_bal,
            }
        )
    else:
        post = {
            auth_a.authority: auth_a.original_account,
            auth_b.authority: auth_b.original_account,
            recipient: expected_recipient,
        }
        expected_block_access_list = BlockAccessListExpectation(
            account_expectations={
                recipient: BalAccountExpectation.empty(),
                auth_a.authority: BalAccountExpectation.empty(),
                auth_b.authority: BalAccountExpectation.empty(),
                **delegation_target_bal,
            }
        )

    state_test(
        pre=pre,
        tx=tx,
        post=post,
        expected_block_access_list=expected_block_access_list,
    )

Parametrized Test Cases

This test generates 4 parametrized test cases across 1 fork.