diff --git a/packages/testing/src/execution_testing/forks/tests/test_paged_storage_trie.py b/packages/testing/src/execution_testing/forks/tests/test_paged_storage_trie.py index b18098a7195..bdb8c7e13d4 100644 --- a/packages/testing/src/execution_testing/forks/tests/test_paged_storage_trie.py +++ b/packages/testing/src/execution_testing/forks/tests/test_paged_storage_trie.py @@ -29,9 +29,12 @@ from ethereum.crypto.hash import Hash32, keccak256 from ethereum.merkle_patricia_trie import ( EMPTY_TRIE_ROOT, + Trie, bytes_to_nibble_list, encode_internal_node, patricialize, + root, + trie_set, ) from ethereum.paged_storage_trie import ( PAGE_SIZE, @@ -202,3 +205,46 @@ def test_single_page_root_matches_manual_reconstruction( expected = keccak256(encoded) if len(encoded) < 32 else Hash32(root_node) assert storage_root_paged(_storage(slot_values)) == expected + + +def _storage_trie(slot_values: Mapping[int, int]) -> Trie[Bytes32, U256]: + """Build a secured storage trie holding `{slot: value}`.""" + trie: Trie[Bytes32, U256] = Trie(secured=True, default=U256(0)) + for slot, value in slot_values.items(): + trie_set(trie, U256(slot).to_be_bytes32(), U256(value)) + return trie + + +@pytest.mark.parametrize( + "slot_values", + [{0: 1}, {0: 1, 1: 2}, {0: 1, 128: 2}, {2**256 - 1: 1}], +) +def test_paged_root_differs_from_keccak_mpt_root( + slot_values: Dict[int, int], +) -> None: + """The page commitment does not coincide with a keccak MPT over slots.""" + assert storage_root_paged(_storage(slot_values)) != root( + _storage_trie(slot_values) + ) + + +def test_fully_cleared_page_root_matches_never_written() -> None: + """ + Clearing every slot of a page restores the root the storage had + before that page was written. + + Zeroing a slot drops it from the trie, so the emptied page has no + commitment entry at all — `page_commit` rejects an all-zero image. + """ + trie = _storage_trie({0: 1}) + baseline = storage_root_paged(trie._data) + + for offset in range(WORDS_PER_PAGE): + slot = U256(WORDS_PER_PAGE + offset).to_be_bytes32() + trie_set(trie, slot, U256(offset + 1)) + assert storage_root_paged(trie._data) != baseline + + for offset in range(WORDS_PER_PAGE): + slot = U256(WORDS_PER_PAGE + offset).to_be_bytes32() + trie_set(trie, slot, U256(0)) + assert storage_root_paged(trie._data) == baseline diff --git a/packages/testing/src/execution_testing/test_types/tests/test_alloc_prestate.py b/packages/testing/src/execution_testing/test_types/tests/test_alloc_prestate.py index 57b558882f3..f96b76d1c69 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_alloc_prestate.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_alloc_prestate.py @@ -11,6 +11,8 @@ 4. Building alloc B by `apply_diff`ing a diff onto alloc A produces a post-state whose root matches an alloc independently constructed to look like the post-state. + 5. `state_root` follows the alloc's commitment scheme, so the same + accounts commit differently under MPT and under MIP-8 paging. """ from typing import Dict, Optional @@ -287,3 +289,18 @@ def test_apply_diff_round_trip_matches_independent_post_state() -> None: account_changes={}, storage_changes={}, code_changes={} ) ) + + +def test_state_root_follows_commitment_scheme() -> None: + """ + Identical accounts commit to different roots under MPT and paging. + + Guards the `StateCommitment` dispatch in `Alloc._state_module`: a + misrouted scheme would silently keep producing pre-MIP-8 roots. + """ + mpt = _fixture_alloc() + paged = _fixture_alloc() + paged.migrate_state_commitment(StateCommitment.PAGED) + + assert mpt.root == paged.root + assert mpt.state_root() != paged.state_root() diff --git a/tests/monad_ten/mip8_pageified_storage/test_fork_transition.py b/tests/monad_ten/mip8_pageified_storage/test_fork_transition.py index d8b966a8e87..70f156bfdac 100644 --- a/tests/monad_ten/mip8_pageified_storage/test_fork_transition.py +++ b/tests/monad_ten/mip8_pageified_storage/test_fork_transition.py @@ -17,6 +17,7 @@ Storage, Transaction, ) +from execution_testing.base_types.conversions import NumberConvertible from execution_testing.forks import MONAD_NINE, MONAD_TEN from execution_testing.forks.helpers import Fork @@ -26,7 +27,7 @@ generous_gas, simulate_sstore, ) -from .spec import ref_spec_8 +from .spec import Spec, ref_spec_8 REFERENCE_SPEC_GIT_PATH = ref_spec_8.git_path REFERENCE_SPEC_VERSION = ref_spec_8.version @@ -495,3 +496,53 @@ def stable_i(i: int) -> Bytecode: blocks=blocks, post={contract_address: Account(storage=storage)}, ) + + +@pytest.mark.parametrize("other_account_touched", [False, True]) +@pytest.mark.valid_at_transition_to("MONAD_TEN") +def test_state_root_untouched_storage_at_fork( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + other_account_touched: bool, +) -> None: + """ + Storage untouched by the post-fork block still commits under the + MIP-8 page scheme. + """ + sender = pre.fund_eoa() + + storage: dict[NumberConvertible, NumberConvertible] = { + 0: 1, + 1: 2**256 - 1, + Spec.SLOTS_PER_PAGE - 1: 0x2A, + Spec.SLOTS_PER_PAGE: 0x2B, + 2 * Spec.SLOTS_PER_PAGE - 1: 0x2C, + 2**256 - 1: 2**255, + } + contract_address = pre.deploy_contract(Op.STOP, storage=storage) + + timestamps = [14_999, 15_000] + post: dict = {contract_address: Account(storage=storage)} + + if other_account_touched: + target = pre.deploy_contract(Op.SSTORE(0, Op.TIMESTAMP)) + post[target] = Account(storage={0: timestamps[-1]}) + else: + target = pre.fund_eoa(amount=0) + + blocks = [ + Block( + timestamp=ts, + txs=[ + Transaction( + to=target, + value=1, + sender=sender, + nonce=nonce, + ), + ], + ) + for nonce, ts in enumerate(timestamps) + ] + + blockchain_test(pre=pre, blocks=blocks, post=post) diff --git a/tests/monad_ten/mip8_pageified_storage/test_sstore_gas.py b/tests/monad_ten/mip8_pageified_storage/test_sstore_gas.py index cc55979ac5e..9037b9d4dd7 100644 --- a/tests/monad_ten/mip8_pageified_storage/test_sstore_gas.py +++ b/tests/monad_ten/mip8_pageified_storage/test_sstore_gas.py @@ -814,6 +814,55 @@ def test_sstore_no_growth_after_clear( ) +@pytest.mark.parametrize("clears", [1, 3]) +def test_sstore_negative_growth_floor( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + clears: int, +) -> None: + """ + The per-page growth counter accumulates below zero, so refills stay + free until it climbs back above the peak it started at. + """ + page = TxPageState(slots=dict.fromkeys(range(clears), 1)) + + code = Bytecode() + for slot in range(clears): + code += Op.SSTORE(slot, 0) + simulate_sstore(page, slot, 0, fork) + + overhead = (Op.PUSH1(0) + Op.PUSH1(0)).gas_cost(fork) + expected_storage: dict[int, int] = {} + for slot in range(clears + 1): + expected_storage[slot] = 1 + expected_storage[slot_gas_measured + slot] = simulate_sstore( + page, slot, 1, fork + ) + code += CodeGasMeasure( + code=Op.SSTORE(slot, 1), + overhead_cost=overhead, + extra_stack_items=0, + sstore_key=slot_gas_measured + slot, + ) + + contract_address = pre.deploy_contract( + code, storage=dict.fromkeys(range(clears), 1) + ) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + state_test( + pre=pre, + post={contract_address: Account(storage=expected_storage)}, + tx=tx, + ) + + @pytest.mark.parametrize( "warm_slot,measured_slot", [ @@ -954,3 +1003,51 @@ def test_sstore_oog( subject_storage=subject_storage, expected_gas=expected_gas, ) + + +@pytest.mark.parametrize("surviving_pages", [1, 2]) +def test_zeroed_page_omitted_from_root( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + surviving_pages: int, +) -> None: + """ + A page whose every slot is cleared is omitted from the storage + commitment rather than committed as an all-zero page. + """ + cleared_page = 1 + kept_pages = [0, 2][:surviving_pages] + + kept_storage: dict[NumberConvertible, NumberConvertible] = {} + for page in kept_pages: + base = page * Spec.SLOTS_PER_PAGE + kept_storage[base] = 1 + kept_storage[base + 1] = 2 + kept_storage[base + Spec.SLOTS_PER_PAGE - 1] = 3 + + cleared_slots = range( + cleared_page * Spec.SLOTS_PER_PAGE, + (cleared_page + 1) * Spec.SLOTS_PER_PAGE, + ) + + code = Bytecode() + for slot in cleared_slots: + code += Op.SSTORE(slot, 0) + + contract_address = pre.deploy_contract( + code, + storage={**kept_storage, **dict.fromkeys(cleared_slots, 0xFF)}, + ) + + tx = Transaction( + gas_limit=generous_gas(fork) + full_page_sweep_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + state_test( + pre=pre, + post={contract_address: Account(storage=kept_storage)}, + tx=tx, + )