|
| 1 | +""" |
| 2 | +State-test regression for the BLOCKHASH (0x40) opcode recency window. |
| 3 | +
|
| 4 | +In a *state test*, nethermind returns a non-zero hash for |
| 5 | +``BLOCKHASH(0)`` even when block 0 lies far outside the recency window (256 |
| 6 | +blocks pre-Prague, 8191 via EIP-2935 from Prague). eels (the reference), |
| 7 | +go-ethereum, besu, erigon, evmone and reth all correctly return 0. |
| 8 | +
|
| 9 | +Root cause (nethermind, state-test only): |
| 10 | +``src/Nethermind/Ethereum.Test.Base/TestBlockhashProvider.cs`` implements:: |
| 11 | +
|
| 12 | + number != 0 ? Keccak.Zero : Keccak.Compute(number.ToString()) |
| 13 | +
|
| 14 | +It performs no recency-window check, and the opcode handler |
| 15 | +``InstructionBlockHash`` (in ``EvmInstructions.Environment.cs``) delegates |
| 16 | +that check to the provider -- it only rejects ``number >= current``. So |
| 17 | +``BLOCKHASH(0)`` returns ``keccak256("0")`` regardless of how ancient block |
| 18 | +0 is. Nethermind's *production* ``BlockhashProvider`` does enforce the |
| 19 | +window, so this is a state-test tooling bug, not a mainnet consensus bug -- |
| 20 | +but it makes nethermind diverge under differential state-test fuzzing. |
| 21 | +""" |
| 22 | + |
| 23 | +import pytest |
| 24 | +from execution_testing import ( |
| 25 | + Account, |
| 26 | + Alloc, |
| 27 | + Environment, |
| 28 | + Hash, |
| 29 | + Op, |
| 30 | + StateTestFiller, |
| 31 | + Storage, |
| 32 | + Transaction, |
| 33 | +) |
| 34 | + |
| 35 | +# keccak256(b"0") -- the state-test convention hash for block 0 used by both |
| 36 | +# go-ethereum (vmTestBlockHash) and nethermind (TestBlockhashProvider). The |
| 37 | +# fuzzer sets env.previousHash to exactly this value so the in-window |
| 38 | +# block-0 hash agrees across clients. |
| 39 | +KECCAK_OF_BLOCK_0 = Hash( |
| 40 | + 0x044852B2A670ADE5407E78FB2863C51DE9FCB96542A07186FE3AEDA6BB8A116D |
| 41 | +) |
| 42 | + |
| 43 | + |
| 44 | +@pytest.mark.valid_from("Frontier") |
| 45 | +@pytest.mark.state_test_only |
| 46 | +@pytest.mark.parametrize( |
| 47 | + "current_number", |
| 48 | + [ |
| 49 | + pytest.param(0x101, id="just_past_256_window"), |
| 50 | + pytest.param(0x2001, id="just_past_8191_window"), |
| 51 | + pytest.param(0x100000, id="far_out_of_window"), |
| 52 | + pytest.param(0xA63CCE, id="fuzzer_discovered_number"), |
| 53 | + ], |
| 54 | +) |
| 55 | +def test_blockhash_zero_out_of_window( |
| 56 | + state_test: StateTestFiller, |
| 57 | + pre: Alloc, |
| 58 | + current_number: int, |
| 59 | +) -> None: |
| 60 | + """ |
| 61 | + BLOCKHASH(0) must be 0 when block 0 is outside the recency window. |
| 62 | +
|
| 63 | + At ``current_number`` well past the window, block 0 is ancient, so every |
| 64 | + spec-conformant client returns 0. Nethermind's state-test runner instead |
| 65 | + returns ``keccak256("0")`` because ``TestBlockhashProvider`` skips the |
| 66 | + window check, which is the bug this test guards against. |
| 67 | +
|
| 68 | + Marked ``state_test_only``: the bug lives solely in the state-test code |
| 69 | + path (the production blockhash provider enforces the window), and a state |
| 70 | + test with such a high ``env.number`` cannot be converted into a real |
| 71 | + blockchain-test fixture (no genesis chain up to that height). |
| 72 | + """ |
| 73 | + storage = Storage() |
| 74 | + contract = pre.deploy_contract( |
| 75 | + code=( |
| 76 | + # slot 0: the raw hash -- must be zero |
| 77 | + Op.SSTORE( |
| 78 | + storage.store_next(0, "blockhash_0_value"), |
| 79 | + Op.BLOCKHASH(0), |
| 80 | + ) |
| 81 | + # slot 1: ISZERO of the hash -- must be 1 (written, so the |
| 82 | + # divergence lands in a non-default slot too) |
| 83 | + + Op.SSTORE( |
| 84 | + storage.store_next(1, "blockhash_0_is_zero"), |
| 85 | + Op.ISZERO(Op.BLOCKHASH(0)), |
| 86 | + ) |
| 87 | + ) |
| 88 | + ) |
| 89 | + sender = pre.fund_eoa() |
| 90 | + |
| 91 | + tx = Transaction( |
| 92 | + sender=sender, |
| 93 | + to=contract, |
| 94 | + gas_limit=200_000, |
| 95 | + protected=False, # legacy tx so it fills on pre-EIP-155 forks too |
| 96 | + ) |
| 97 | + |
| 98 | + state_test( |
| 99 | + env=Environment(number=current_number), |
| 100 | + pre=pre, |
| 101 | + post={contract: Account(storage=storage)}, |
| 102 | + tx=tx, |
| 103 | + ) |
| 104 | + |
| 105 | + |
| 106 | +@pytest.mark.valid_from("Frontier") |
| 107 | +@pytest.mark.valid_until("Cancun") |
| 108 | +@pytest.mark.state_test_only |
| 109 | +def test_blockhash_zero_in_window_control( |
| 110 | + state_test: StateTestFiller, |
| 111 | + pre: Alloc, |
| 112 | +) -> None: |
| 113 | + """ |
| 114 | + In-window control: BLOCKHASH(0) returns the block-0 hash at block 1. |
| 115 | +
|
| 116 | + This is the positive counterpart to the out-of-window test. With block 0 |
| 117 | + inside the recency window (current block == 1) and ``previousHash`` set |
| 118 | + to the state-test convention value ``keccak256("0")``, all clients -- |
| 119 | + including nethermind -- agree on the result, pinning the window boundary. |
| 120 | +
|
| 121 | + Restricted to <= Cancun because EIP-2935 (Prague+) serves BLOCKHASH from |
| 122 | + the history storage contract, which is not pre-populated in a bare state |
| 123 | + test. |
| 124 | + Marked ``state_test_only``: the asserted non-zero hash would not match the |
| 125 | + real genesis hash of a derived blockchain-test fixture. |
| 126 | + """ |
| 127 | + storage = Storage() |
| 128 | + contract = pre.deploy_contract( |
| 129 | + code=Op.SSTORE( |
| 130 | + storage.store_next(KECCAK_OF_BLOCK_0, "blockhash_0_value"), |
| 131 | + Op.BLOCKHASH(0), |
| 132 | + ) |
| 133 | + ) |
| 134 | + sender = pre.fund_eoa() |
| 135 | + |
| 136 | + tx = Transaction( |
| 137 | + sender=sender, |
| 138 | + to=contract, |
| 139 | + gas_limit=200_000, |
| 140 | + protected=False, # legacy tx so it fills on pre-EIP-155 forks too |
| 141 | + ) |
| 142 | + |
| 143 | + state_test( |
| 144 | + env=Environment( |
| 145 | + number=1, |
| 146 | + block_hashes={0: KECCAK_OF_BLOCK_0}, |
| 147 | + ), |
| 148 | + pre=pre, |
| 149 | + post={contract: Account(storage=storage)}, |
| 150 | + tx=tx, |
| 151 | + ) |
0 commit comments