Skip to content

Add Blockhash library following EIP-2935 #5642

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Apr 29, 2025
5 changes: 5 additions & 0 deletions .changeset/wet-dodos-reply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`Blockhash`: Add a library that provides access to historical block hashes using EIP-2935's history storage, extending the standard 256-block limit to 8191 blocks.
9 changes: 9 additions & 0 deletions contracts/mocks/BlockhashMock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Blockhash} from "../utils/Blockhash.sol";

/// @dev This mock is required for upgradeable tests to pass
contract BlockhashMock {

}
49 changes: 49 additions & 0 deletions contracts/utils/Blockhash.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/**
* @dev Library for accessing historical block hashes beyond the standard 256 block limit.
* Uses EIP-2935's history storage contract which maintains a ring buffer of the last
* 8191 block hashes in state.
*
* For blocks within the last 256 blocks, it uses the native `BLOCKHASH` opcode.
* For blocks between 257 and 8191 blocks ago, it queries the EIP-2935 history storage.
* For blocks older than 8191 or future blocks, it returns zero, matching the `BLOCKHASH` behavior.
*
* NOTE: After EIP-2935 activation, it takes 8191 blocks to completely fill the history.
* Before that, only block hashes since the fork block will be available.
*/
library Blockhash {
address internal constant HISTORY_STORAGE_ADDRESS = 0x0000F90827F1C53a10cb7A02335B175320002935;

/**
* @dev Retrieves the block hash for any historical block within the supported range.
*
* NOTE: The function gracefully handles future blocks and blocks beyond the history window
* by returning zero, consistent with the EVM's native `BLOCKHASH` behavior.
*/
function blockHash(uint256 blockNumber) internal view returns (bytes32) {
uint256 current = block.number;
uint256 distance;

unchecked {
// Can only wrap around to `current + 1` given `block.number - (2**256 - 1) = block.number + 1`
distance = current - blockNumber;
}

return distance > 256 && distance <= 8191 ? _historyStorageCall(blockNumber) : blockhash(blockNumber);
}

/// @dev Internal function to query the EIP-2935 history storage contract.
function _historyStorageCall(uint256 blockNumber) private view returns (bytes32 hash) {
assembly ("memory-safe") {
mstore(0, blockNumber) // Store the blockNumber in scratch space

// In case the history storage address is not deployed, the call will succeed
// without returndata, so the hash will be 0 just as querying `blockhash` directly.
if and(gt(returndatasize(), 0), staticcall(gas(), HISTORY_STORAGE_ADDRESS, 0, 0x20, 0, 0x20)) {
hash := mload(0)
}
}
}
}
2 changes: 1 addition & 1 deletion foundry.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[profile.default]
solc_version = '0.8.24'
evm_version = 'cancun'
evm_version = 'prague'
optimizer = true
optimizer-runs = 200
src = 'contracts'
Expand Down
6 changes: 6 additions & 0 deletions hardhat/common-contracts.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ const INSTANCES = {
bytecode: '0x60003681823780368234f58015156014578182fd5b80825250506014600cf3',
},
},
eip2935: {
address: '0x0000F90827F1C53a10cb7A02335B175320002935',
abi: [],
bytecode:
'0x3373fffffffffffffffffffffffffffffffffffffffe14604657602036036042575f35600143038111604257611fff81430311604257611fff9006545f5260205ff35b5f5ffd5b5f35611fff60014303065500',
},
};

const setup = (input, ethers) =>
Expand Down
100 changes: 100 additions & 0 deletions test/utils/Blockhash.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {Blockhash} from "../../contracts/utils/Blockhash.sol";

contract BlockhashTest is Test {
uint256 internal startingBlock;

address internal constant SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE;

// See https://eips.ethereum.org/EIPS/eip-2935#bytecode
// Generated using https://www.evm.codes/playground
bytes private constant HISTORY_STORAGE_BYTECODE =
hex"3373fffffffffffffffffffffffffffffffffffffffe14604657602036036042575f35600143038111604257611fff81430311604257611fff9006545f5260205ff35b5f5ffd5b5f35611fff60014303065500";

function setUp() public {
vm.roll(block.number + 100);

startingBlock = block.number;
vm.etch(Blockhash.HISTORY_STORAGE_ADDRESS, HISTORY_STORAGE_BYTECODE);
}

function testFuzzRecentBlocks(uint8 offset, uint64 currentBlock, bytes32 expectedHash) public {
// Recent blocks (1-256 blocks old)
uint256 boundedOffset = uint256(offset) + 1;
vm.assume(currentBlock > boundedOffset);
vm.roll(currentBlock);

uint256 targetBlock = currentBlock - boundedOffset;
vm.setBlockhash(targetBlock, expectedHash);

bytes32 result = Blockhash.blockHash(targetBlock);
assertEq(result, blockhash(targetBlock));
assertEq(result, expectedHash);
}

function testFuzzHistoryBlocks(uint16 offset, uint256 currentBlock, bytes32 expectedHash) public {
// History blocks (257-8191 blocks old)
offset = uint16(bound(offset, 257, 8191));
vm.assume(currentBlock > offset);
vm.roll(currentBlock);

uint256 targetBlock = currentBlock - offset;
_setHistoryBlockhash(targetBlock, expectedHash);

bytes32 result = Blockhash.blockHash(targetBlock);
(bool success, bytes memory returndata) = Blockhash.HISTORY_STORAGE_ADDRESS.staticcall(
abi.encodePacked(bytes32(targetBlock))
);
assertTrue(success);
assertEq(result, abi.decode(returndata, (bytes32)));
assertEq(result, expectedHash);
}

function testFuzzVeryOldBlocks(uint256 offset, uint256 currentBlock) public {
// Very old blocks (>8191 blocks old)
offset = bound(offset, 8192, type(uint256).max);
vm.assume(currentBlock > offset);
vm.roll(currentBlock);

uint256 targetBlock = currentBlock - offset;
bytes32 result = Blockhash.blockHash(targetBlock);
assertEq(result, bytes32(0));
}

function testFuzzFutureBlocks(uint256 offset, uint256 currentBlock) public {
// Future blocks
offset = bound(offset, 1, type(uint256).max);
vm.roll(currentBlock);

unchecked {
uint256 targetBlock = currentBlock + offset;
bytes32 result = Blockhash.blockHash(targetBlock);
assertEq(result, blockhash(targetBlock));
}
}

function testUnsupportedChainsReturnZeroWhenOutOfRange() public {
vm.etch(Blockhash.HISTORY_STORAGE_ADDRESS, hex"");

vm.roll(block.number + 1000);
assertEq(Blockhash.blockHash(block.number - 1000), bytes32(0));
}

function _setHistoryBlockhash(bytes32 blockHash) internal {
_setHistoryBlockhash(block.number, blockHash);
}

function _setHistoryBlockhash(uint256 blockNumber, bytes32 blockHash) internal {
// Subtracting 1 due to bug encountered during coverage
uint256 currentBlock = block.number - 1;
vm.assume(blockNumber < type(uint256).max);
vm.roll(blockNumber + 1); // roll to the next block so the storage contract sets the parent's blockhash
vm.prank(SYSTEM_ADDRESS);
(bool success, ) = Blockhash.HISTORY_STORAGE_ADDRESS.call(abi.encode(blockHash)); // set parent's blockhash
assertTrue(success);
vm.roll(currentBlock + 1);
}
}
76 changes: 76 additions & 0 deletions test/utils/Blockhash.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture, mine, mineUpTo, setCode } = require('@nomicfoundation/hardhat-network-helpers');
const { impersonate } = require('../helpers/account');

async function fixture() {
const mock = await ethers.deployContract('$Blockhash');
return { mock };
}

const HISTORY_STORAGE_ADDRESS = '0x0000F90827F1C53a10cb7A02335B175320002935';
const SYSTEM_ADDRESS = '0xfffffffffffffffffffffffffffffffffffffffe';
const HISTORY_SERVE_WINDOW = 8191;
const BLOCKHASH_SERVE_WINDOW = 256;

describe('Blockhash', function () {
before(async function () {
Object.assign(this, await loadFixture(fixture));

impersonate(SYSTEM_ADDRESS);
this.systemSigner = await ethers.getSigner(SYSTEM_ADDRESS);
});

it('recent block', async function () {
await mine();

const mostRecentBlock = (await ethers.provider.getBlock('latest')).number;
const blockToCheck = mostRecentBlock - 1;
const fetchedHash = (await ethers.provider.getBlock(blockToCheck)).hash;
await expect(this.mock.$blockHash(blockToCheck)).to.eventually.equal(fetchedHash);
});

it('old block', async function () {
await mine();

const mostRecentBlock = await ethers.provider.getBlock('latest');

// Call the history address with the most recent block hash
await this.systemSigner.sendTransaction({
to: HISTORY_STORAGE_ADDRESS,
data: mostRecentBlock.hash,
});

await mineUpTo(mostRecentBlock.number + BLOCKHASH_SERVE_WINDOW + 10);

// Verify blockhash after setting history
await expect(this.mock.$blockHash(mostRecentBlock.number)).to.eventually.equal(mostRecentBlock.hash);
});

it('very old block', async function () {
await mine();

const mostRecentBlock = await ethers.provider.getBlock('latest');
await mineUpTo(mostRecentBlock.number + HISTORY_SERVE_WINDOW + 10);

await expect(this.mock.$blockHash(mostRecentBlock.number)).to.eventually.equal(ethers.ZeroHash);
});

it('future block', async function () {
await mine();

const mostRecentBlock = await ethers.provider.getBlock('latest');
const blockToCheck = mostRecentBlock.number + 10;
await expect(this.mock.$blockHash(blockToCheck)).to.eventually.equal(ethers.ZeroHash);
});

it('unsupported chain', async function () {
await setCode(HISTORY_STORAGE_ADDRESS, '0x00');

const mostRecentBlock = await ethers.provider.getBlock('latest');
await mineUpTo(mostRecentBlock.number + BLOCKHASH_SERVE_WINDOW + 10);

await expect(this.mock.$blockHash(mostRecentBlock.number)).to.eventually.equal(ethers.ZeroHash);
await expect(this.mock.$blockHash(mostRecentBlock.number + 20)).to.eventually.not.equal(ethers.ZeroHash);
});
});