diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/corruptions.py b/packages/testing/src/execution_testing/test_types/block_access_list/corruptions.py new file mode 100644 index 00000000000..0c6691a6c06 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/block_access_list/corruptions.py @@ -0,0 +1,308 @@ +""" +Corruption catalog for EIP-7928 Block Access Lists. + +Derives Correctness and Completeness corruption cases from a valid +``BlockAccessListExpectation``. Each case bundles: + + - a stable ``id`` for pytest parametrization, + - a ``modifier`` that, when applied to the real t8n-produced BAL at + fill time, yields a corrupted variant, + - the ``expected_exception`` the client must raise on that variant. + +The catalog is pure: it never touches a real BAL and has no side +effects. Higher-level nibbles wire these cases into pytest. + +## ID format + +:: + +
______ - nonce, balance, code +
____storage____ - storage_write +
____storage_read__ - storage_read (no bai) +
__missing - account-level missing + +``
`` is ``str(address)`` — the canonical 0x-prefixed lowercase +hex form. ```` is the slot key as 0x-prefixed lowercase hex. +Using the address directly makes IDs independent of dict iteration +order. ```` is ``wrong`` (Correctness) or ``missing`` +(Completeness). + +## Cases + +- ``wrong`` — XOR-flip an existing value (Correctness). +- ``missing`` — drop an existing entry or whole account (Completeness). +""" + +from typing import Callable, List, NamedTuple + +from execution_testing.base_types import Address, ZeroPaddedHexNumber +from execution_testing.exceptions import BlockException + +from .expectations import BalAccountExpectation, BlockAccessListExpectation +from .modifiers import ( + modify_balance, + modify_code, + modify_nonce, + modify_storage, + remove_accounts, +) +from .t8n import BlockAccessList + +Modifier = Callable[[BlockAccessList], BlockAccessList] + + +class CorruptionCase(NamedTuple): + """ + One corruption derived from a valid BAL expectation. + + Fields + ------ + id + Stable string for pytest parametrization. See module docstring + for the naming convention. + modifier + Callable that, applied to the real t8n-produced BAL at fill + time, yields the corrupted variant. + expected_exception + The ``BlockException`` the client must raise when verifying the + corrupted block. + """ + + id: str + modifier: Modifier + expected_exception: BlockException + + +_INVALID_BAL = BlockException.INVALID_BLOCK_ACCESS_LIST +_FLIP_MASK = 0xFF + + +def _flip(value: int) -> int: + """XOR-flip an integer to guarantee a distinct value.""" + return value ^ _FLIP_MASK + + +def _flip_bytes(value: bytes) -> bytes: + r"""XOR-flip the first byte of a bytes value, or return ``\\xff``.""" + if not value: + return b"\xff" + return bytes([value[0] ^ 0xFF]) + value[1:] + + +def _remove_change_at(address: Address, field_name: str, bai: int) -> Modifier: + """Drop one change in ``field_name`` at the given block_access_index.""" + + def transform(bal: BlockAccessList) -> BlockAccessList: + new_root = [] + for ac in bal.root: + if ac.address == address: + new_ac = ac.model_copy(deep=True) + old = getattr(new_ac, field_name) + setattr( + new_ac, + field_name, + [c for c in old if int(c.block_access_index) != bai], + ) + new_root.append(new_ac) + else: + new_root.append(ac) + return BlockAccessList(root=new_root) + + return transform + + +def _remove_slot_change_at(address: Address, slot: int, bai: int) -> Modifier: + """Drop one slot change in ``storage_changes[slot]`` at ``bai``.""" + + def transform(bal: BlockAccessList) -> BlockAccessList: + new_root = [] + for ac in bal.root: + if ac.address == address: + new_ac = ac.model_copy(deep=True) + for storage_slot in new_ac.storage_changes: + if int(storage_slot.slot) == slot: + storage_slot.slot_changes = [ + c + for c in storage_slot.slot_changes + if int(c.block_access_index) != bai + ] + break + new_root.append(new_ac) + else: + new_root.append(ac) + return BlockAccessList(root=new_root) + + return transform + + +def _remove_storage_read_at(address: Address, slot: int) -> Modifier: + """Drop a storage_read entry by slot key.""" + + def transform(bal: BlockAccessList) -> BlockAccessList: + new_root = [] + for ac in bal.root: + if ac.address == address: + new_ac = ac.model_copy(deep=True) + new_ac.storage_reads = [ + s for s in new_ac.storage_reads if int(s) != slot + ] + new_root.append(new_ac) + else: + new_root.append(ac) + return BlockAccessList(root=new_root) + + return transform + + +def _replace_storage_read_slot( + address: Address, old_slot: int, new_slot: int +) -> Modifier: + """Replace a storage_read's slot key with a flipped value.""" + + def transform(bal: BlockAccessList) -> BlockAccessList: + new_root = [] + for ac in bal.root: + if ac.address == address: + new_ac = ac.model_copy(deep=True) + reads = list(new_ac.storage_reads) + for i, s in enumerate(reads): + if int(s) == old_slot: + reads[i] = ZeroPaddedHexNumber(new_slot) + break + new_ac.storage_reads = reads + new_root.append(new_ac) + else: + new_root.append(ac) + return BlockAccessList(root=new_root) + + return transform + + +def _account_cases( + address: Address, account_exp: BalAccountExpectation +) -> List[CorruptionCase]: + """Emit Correctness + Completeness cases for one account.""" + cases: List[CorruptionCase] = [] + addr = str(address) + + for nc in account_exp.nonce_changes: + bai = int(nc.block_access_index) + cases.append( + CorruptionCase( + id=f"{addr}__wrong__nonce__{bai}", + modifier=modify_nonce(address, bai, _flip(int(nc.post_nonce))), + expected_exception=_INVALID_BAL, + ) + ) + cases.append( + CorruptionCase( + id=f"{addr}__missing__nonce__{bai}", + modifier=_remove_change_at(address, "nonce_changes", bai), + expected_exception=_INVALID_BAL, + ) + ) + + for bc in account_exp.balance_changes: + bai = int(bc.block_access_index) + cases.append( + CorruptionCase( + id=f"{addr}__wrong__balance__{bai}", + modifier=modify_balance( + address, bai, _flip(int(bc.post_balance)) + ), + expected_exception=_INVALID_BAL, + ) + ) + cases.append( + CorruptionCase( + id=f"{addr}__missing__balance__{bai}", + modifier=_remove_change_at(address, "balance_changes", bai), + expected_exception=_INVALID_BAL, + ) + ) + + for cc in account_exp.code_changes: + bai = int(cc.block_access_index) + cases.append( + CorruptionCase( + id=f"{addr}__wrong__code__{bai}", + modifier=modify_code( + address, bai, _flip_bytes(bytes(cc.new_code)) + ), + expected_exception=_INVALID_BAL, + ) + ) + cases.append( + CorruptionCase( + id=f"{addr}__missing__code__{bai}", + modifier=_remove_change_at(address, "code_changes", bai), + expected_exception=_INVALID_BAL, + ) + ) + + for ss in account_exp.storage_changes: + slot = int(ss.slot) + for sc in ss.slot_changes: + bai = int(sc.block_access_index) + cases.append( + CorruptionCase( + id=f"{addr}__wrong__storage__{slot:#x}__{bai}", + modifier=modify_storage( + address, bai, slot, _flip(int(sc.post_value)) + ), + expected_exception=_INVALID_BAL, + ) + ) + cases.append( + CorruptionCase( + id=f"{addr}__missing__storage__{slot:#x}__{bai}", + modifier=_remove_slot_change_at(address, slot, bai), + expected_exception=_INVALID_BAL, + ) + ) + + for sr in account_exp.storage_reads: + slot = int.from_bytes(bytes(sr), "big") + cases.append( + CorruptionCase( + id=f"{addr}__wrong__storage_read__{slot:#x}", + modifier=_replace_storage_read_slot( + address, slot, _flip(slot) + ), + expected_exception=_INVALID_BAL, + ) + ) + cases.append( + CorruptionCase( + id=f"{addr}__missing__storage_read__{slot:#x}", + modifier=_remove_storage_read_at(address, slot), + expected_exception=_INVALID_BAL, + ) + ) + + cases.append( + CorruptionCase( + id=f"{addr}__missing", + modifier=remove_accounts(address), + expected_exception=_INVALID_BAL, + ) + ) + + return cases + + +def enumerate_corruptions( + expectation: BlockAccessListExpectation, +) -> List[CorruptionCase]: + """ + Return every Correctness and Completeness case derivable from + ``expectation``. + + See the module docstring for the ID format and the cases emitted. + """ + cases: List[CorruptionCase] = [] + for address, account_exp in expectation.account_expectations.items(): + if account_exp is None: + continue + cases.extend(_account_cases(address, account_exp)) + return cases diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_corruptions.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_corruptions.py new file mode 100644 index 00000000000..aeb8941a9b2 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_corruptions.py @@ -0,0 +1,199 @@ +"""Unit tests for the BAL corruption catalog.""" + +from execution_testing.base_types import Address +from execution_testing.test_types.block_access_list import ( + BalAccountExpectation, + BalBalanceChange, + BalNonceChange, + BlockAccessListExpectation, +) +from execution_testing.test_types.block_access_list.account_changes import ( + BalCodeChange, + BalStorageChange, + BalStorageSlot, +) +from execution_testing.test_types.block_access_list.corruptions import ( + enumerate_corruptions, +) + +ETH = 10**18 + +ALICE = Address(0xA11CE) +BOB = Address(0xB0B) +ORACLE = Address(0x04AC1E) + + +def test_enumerate_corruptions_alice_bob() -> None: + """ + Alice starts with 5 ETH and transfers 1 ETH to Bob. Gas is ignored + for simplicity — so Alice ends with 4 ETH and Bob with 1 ETH. + + Shape counts:: + + A = 2 (alice, bob) + C = 3 (alice.nonce, alice.balance, bob.balance) + + Total:: + + N = 2C + A + = 2(3) + 2 + = 8 + + Per-property breakdown:: + + wrong (Correctness) = C = 3 + missing per change (Compl.) = C = 3 + missing per account (Compl.)= A = 2 + """ + expectation = BlockAccessListExpectation( + account_expectations={ + ALICE: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1), + ], + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=4 * ETH + ), + ], + ), + BOB: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=1 * ETH + ), + ], + ), + } + ) + + cases = enumerate_corruptions(expectation) + + alice = str(ALICE) + bob = str(BOB) + + expected_ids = { + # wrong (3) + f"{alice}__wrong__nonce__1", + f"{alice}__wrong__balance__1", + f"{bob}__wrong__balance__1", + # missing per change (3) + f"{alice}__missing__nonce__1", + f"{alice}__missing__balance__1", + f"{bob}__missing__balance__1", + # missing per account (2) + f"{alice}__missing", + f"{bob}__missing", + } + + assert len(cases) == 8 + assert {c.id for c in cases} == expected_ids + + +def test_enumerate_corruptions_complex() -> None: + """ + Two-transaction block exercising every change kind. Gas ignored. + + Tx 1: Alice deploys contract Oracle and sends 1 ETH on creation. + alice: nonce@1, balance@1 (5→4 ETH); + oracle: nonce@1 (EIP-161 contract-creation bump), + balance@1 (0→1 ETH), code@1. + Tx 2: Alice calls Oracle and sends 1 ETH. Oracle reads slot 0x42 and + writes slot 0x43@2. + alice: nonce@2, balance@2 (4→3 ETH); + oracle: balance@2 (1→2 ETH), storage@0x43, storage_read@0x42. + + Shape counts:: + + A = 2 (alice, oracle) + C = 10 (alice.nonce x2, alice.balance x2, + oracle.nonce, oracle.balance x2, oracle.code, + oracle.storage_write, oracle.storage_read) + + Total:: + + N = 2C + A + = 2(10) + 2 + = 22 + """ + expectation = BlockAccessListExpectation( + account_expectations={ + ALICE: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1), + BalNonceChange(block_access_index=2, post_nonce=2), + ], + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=4 * ETH + ), + BalBalanceChange( + block_access_index=2, post_balance=3 * ETH + ), + ], + ), + ORACLE: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1), + ], + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=1 * ETH + ), + BalBalanceChange( + block_access_index=2, post_balance=2 * ETH + ), + ], + code_changes=[ + BalCodeChange(block_access_index=1, new_code=b"\x60\x60"), + ], + storage_changes=[ + BalStorageSlot( + slot=0x43, + slot_changes=[ + BalStorageChange( + block_access_index=2, post_value=99 + ), + ], + ), + ], + storage_reads=[0x42], + ), + } + ) + + cases = enumerate_corruptions(expectation) + + alice = str(ALICE) + oracle = str(ORACLE) + + expected_ids = { + # wrong (10) + f"{alice}__wrong__nonce__1", + f"{alice}__wrong__nonce__2", + f"{alice}__wrong__balance__1", + f"{alice}__wrong__balance__2", + f"{oracle}__wrong__nonce__1", + f"{oracle}__wrong__balance__1", + f"{oracle}__wrong__balance__2", + f"{oracle}__wrong__code__1", + f"{oracle}__wrong__storage__0x43__2", + f"{oracle}__wrong__storage_read__0x42", + # missing per change (10) + f"{alice}__missing__nonce__1", + f"{alice}__missing__nonce__2", + f"{alice}__missing__balance__1", + f"{alice}__missing__balance__2", + f"{oracle}__missing__nonce__1", + f"{oracle}__missing__balance__1", + f"{oracle}__missing__balance__2", + f"{oracle}__missing__code__1", + f"{oracle}__missing__storage__0x43__2", + f"{oracle}__missing__storage_read__0x42", + # missing per account (2) + f"{alice}__missing", + f"{oracle}__missing", + } + + assert len(cases) == 22 + assert {c.id for c in cases} == expected_ids