Skip to content

Arbitrum EIP-2935 historical block hashes#606

Merged
svlachakis merged 3 commits intomainfrom
510-historical-blockhash
Jan 30, 2026
Merged

Arbitrum EIP-2935 historical block hashes#606
svlachakis merged 3 commits intomainfrom
510-historical-blockhash

Conversation

@svlachakis
Copy link
Collaborator

@svlachakis svlachakis commented Jan 29, 2026

Fixes Closes #510

EIP-2935 Historical Block Hash Implementation - Why it's already working

Background

There was concern that Nethermind-Arbitrum doesn't implement EIP-2935 (historical block hash storage) for ArbOS 40+, based on:

  1. An empty code block at ArbitrumTransactionProcessor.cs
  2. Nitro executing custom EIP-2935 contract code during internal transaction processing
  3. Nethermind appearing to do nothing for ParentBlockHashSupport

Investigation: The implementation should be working correctly and it's already producing identical results to Nitro, based on TestHistoricalBlockhashComparison System Test.

Fixed by PRs:

How Nitro Implements EIP-2935

Nitro uses a custom EIP-2935 contract deployed at ArbOS 40. During internal transaction processing (internal_tx.go), Nitro:

  1. Calls the EIP-2935 contract with the previous block hash as calldata
  2. The contract executes EVM code that calls ArbSys.ArbBlockNumber() to get the L2 block number
  3. Stores parentBlockHash at storage slot blockNumber % 393168

How Nethermind-Arbitrum Implements EIP-2935

Nethermind-Arbitrum achieves the same result through a different mechanism:

1. Inheritance from base BlockProcessor

2. Base class handles EIP-2935 storage

In the upstream Nethermind BlockProcessor.ProcessBlock() (line 103):

blockHashStore.ApplyBlockhashStateChanges(header, spec);

This is called before any transactions are processed.

3. ApplyBlockhashStateChanges() directly writes to state

public void ApplyBlockhashStateChanges(BlockHeader blockHeader, IReleaseSpec spec)
{
    if (!spec.IsEip2935Enabled || blockHeader.IsGenesis || blockHeader.ParentHash is null) return;
    
    Address eip2935Account = spec.Eip2935ContractAddress ?? Eip2935Constants.BlockHashHistoryAddress;
    if (!worldState.IsContract(eip2935Account)) return;
    
    Hash256 parentBlockHash = blockHeader.ParentHash;
    UInt256 parentBlockIndex = new UInt256((ulong)((blockHeader.Number - 1) % spec.Eip2935RingBufferSize));
    StorageCell blockHashStoreCell = new(eip2935Account, parentBlockIndex);
    worldState.Set(blockHashStoreCell, parentBlockHash.Bytes.WithoutLeadingZeros().ToArray());
}

4. Arbitrum's custom ring buffer size is configured

// chainspec files (arbitrum-sepolia.json, arbitrum-local.json, etc.)
"eip2935RingBufferSize": "0x5FFD0"  // 393,168

5. Custom EIP-2935 contract code is deployed at ArbOS 40

case 40: // ArbosVersion_40
    // Deploy Arbitrum's custom EIP-2935 contract
    worldState.CreateAccountIfNotExists(Eip2935Constants.BlockHashHistoryAddress, UInt256.Zero, UInt256.One);
    worldState.InsertCode(Eip2935Constants.BlockHashHistoryAddress, Precompiles.HistoryStorageCodeHash,
        Precompiles.HistoryStorageCodeArbitrum, genesisSpec, true);

Why The Empty Block Is Not A Bug

// ArbitrumTransactionProcessor.cs
if (_arbosState!.CurrentArbosVersion >= ArbosVersion.ParentBlockHashSupport)
{
}

This is empty because:

  • The StartBlock internal transaction processing happens after BlockProcessor.ProcessBlock() has already called ApplyBlockhashStateChanges()
  • The block hash is already stored in the EIP-2935 contract storage before this code runs
  • No additional work is needed

Why TestHistoricalBlockHashComparison Passes

The test passes because:

  1. Both clients write the same parentBlockHash to the same storage slot
  2. Both use the same ring buffer size (393,168)
  3. Both use the same EIP-2935 contract address
  4. The BLOCKHASH opcode reads from the same storage location in both clients

When IsBlockHashInStateAvailable is true (EIP-7709, enabled with ArbOS 40+), the ArbitrumBlockhashProvider reads from state:

if (spec?.IsBlockHashInStateAvailable is true)
{
    return _blockhashStore.GetBlockHashFromState(currentBlock, number, spec);
}

Key Insight

The difference between Nitro and Nethermind is HOW they write, not WHAT they write.

  • Nitro: Executes EVM contract code (produces gas traces, matches exact Geth behavior)
  • Nethermind: Direct state write (more efficient, same end result)

For block hash parity (which is what matters for consensus), both approaches produce identical storage state. The TestHistoricalBlockHashComparison system test validates this by comparing block hashes between Nitro and Nethermind-Arbitrum.

Conclusion

  1. Removed unnecessary if on ArbitrumBlockProcessor
  2. The EIP-2935 implementation in Nethermind-Arbitrum is complete and should be working correctly. The actual work is handled by the inherited base BlockProcessor class.

@github-actions
Copy link
Contributor

Code Coverage

Package Line Rate Branch Rate Health
Nethermind.Arbitrum 81% 59%
Summary 81% (9352 / 11487) 59% (2379 / 4012)

Minimum allowed line rate is 60%

@svlachakis svlachakis merged commit aa1dd81 into main Jan 30, 2026
5 checks passed
@svlachakis svlachakis deleted the 510-historical-blockhash branch January 30, 2026 12:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support custom Arbitrum EIP-2935 historical block hashes precompile (sepolia sync)

3 participants