From f5aec4125e1782ea773eae83a4543293c8be24aa Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 21 Apr 2025 11:09:26 -0600 Subject: [PATCH 1/8] Add Blockhash library following EIP-2935 --- .changeset/wet-dodos-reply.md | 5 ++++ contracts/utils/Blockhash.sol | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 .changeset/wet-dodos-reply.md create mode 100644 contracts/utils/Blockhash.sol diff --git a/.changeset/wet-dodos-reply.md b/.changeset/wet-dodos-reply.md new file mode 100644 index 00000000000..00ebaddb6ca --- /dev/null +++ b/.changeset/wet-dodos-reply.md @@ -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. diff --git a/contracts/utils/Blockhash.sol b/contracts/utils/Blockhash.sol new file mode 100644 index 00000000000..456865e3187 --- /dev/null +++ b/contracts/utils/Blockhash.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/** + * @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") { + // Use scratch space to allocate blockNumber + mstore(0, 0x20) // Store length and clear potentially dirty scratch space + mstore(0x20, 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. + let success := staticcall(gas(), HISTORY_STORAGE_ADDRESS, 0x20, 0, 0, 0x20) + // In case of failure, the returndata might include the revert reason or custom error + if success { + hash := mload(0) + } + } + } +} From aba1a63cd897cfa6dd45597b7102eab659399c4a Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 21 Apr 2025 18:55:55 -0600 Subject: [PATCH 2/8] Add failing tests --- contracts/utils/Blockhash.sol | 6 +-- foundry.toml | 2 +- test/utils/Blockhash.t.sol | 84 +++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 test/utils/Blockhash.t.sol diff --git a/contracts/utils/Blockhash.sol b/contracts/utils/Blockhash.sol index 456865e3187..bfbbfb83ff1 100644 --- a/contracts/utils/Blockhash.sol +++ b/contracts/utils/Blockhash.sol @@ -38,12 +38,12 @@ library Blockhash { function _historyStorageCall(uint256 blockNumber) private view returns (bytes32 hash) { assembly ("memory-safe") { // Use scratch space to allocate blockNumber - mstore(0, 0x20) // Store length and clear potentially dirty scratch space - mstore(0x20, blockNumber) // Store the blockNumber in scratch space + 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. - let success := staticcall(gas(), HISTORY_STORAGE_ADDRESS, 0x20, 0, 0, 0x20) + let success := staticcall(gas(), HISTORY_STORAGE_ADDRESS, 0, 0x20, 0, 0x20) + // In case of failure, the returndata might include the revert reason or custom error if success { hash := mload(0) diff --git a/foundry.toml b/foundry.toml index 78dd0781224..7a2e8a60942 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,6 +1,6 @@ [profile.default] solc_version = '0.8.24' -evm_version = 'cancun' +evm_version = 'prague' optimizer = true optimizer-runs = 200 src = 'contracts' diff --git a/test/utils/Blockhash.t.sol b/test/utils/Blockhash.t.sol new file mode 100644 index 00000000000..6593831df7b --- /dev/null +++ b/test/utils/Blockhash.t.sol @@ -0,0 +1,84 @@ +// 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 SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; + + // See https://eips.ethereum.org/EIPS/eip-2935#bytecode + // Generated using https://www.evm.codes/playground + bytes HISTORY_STORAGE_BYTECODE = + hex"3373fffffffffffffffffffffffffffffffffffffffe14604657602036036042575f35600143038111604257611fff81430311604257611fff9006545f5260205ff35b5f5ffd5b5f35611fff60014303065500"; + + function setUp() public { + startingBlock = block.number; + vm.etch(Blockhash.HISTORY_STORAGE_ADDRESS, HISTORY_STORAGE_BYTECODE); + } + + function testFuzzRecentBlocks(uint256 offset, uint256 currentBlock, bytes32 expectedHash) public { + // Recent blocks (1-256 blocks old) + offset = bound(offset, 1, 256); + vm.assume(currentBlock > offset); + vm.roll(currentBlock); + + uint256 targetBlock = currentBlock - offset; + vm.setBlockhash(targetBlock, expectedHash); + + bytes32 result = Blockhash.blockHash(targetBlock); + assertEq(result, blockhash(targetBlock)); + assertEq(result, expectedHash); + } + + function testFuzzHistoryBlocks(uint256 offset, uint256 currentBlock, bytes32 expectedHash) public { + // History blocks (257-8191 blocks old) + offset = bound(offset, 257, 8191); + vm.assume(currentBlock > offset); + vm.roll(currentBlock); + _setHistoryBlockhash(expectedHash); + + uint256 targetBlock = currentBlock - offset; + 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 _setHistoryBlockhash(bytes32 blockHash) internal { + vm.assume(block.number < type(uint256).max); + vm.roll(block.number + 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(block.number - 1); + } +} From 568f692fe8d36cc3742bdfb99425138275486801 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 21 Apr 2025 18:57:31 -0600 Subject: [PATCH 3/8] nits --- test/utils/Blockhash.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/utils/Blockhash.t.sol b/test/utils/Blockhash.t.sol index 6593831df7b..af7682d5e9e 100644 --- a/test/utils/Blockhash.t.sol +++ b/test/utils/Blockhash.t.sol @@ -7,11 +7,11 @@ import {Blockhash} from "../../contracts/utils/Blockhash.sol"; contract BlockhashTest is Test { uint256 internal startingBlock; - address internal SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; + address internal constant SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; // See https://eips.ethereum.org/EIPS/eip-2935#bytecode // Generated using https://www.evm.codes/playground - bytes HISTORY_STORAGE_BYTECODE = + bytes private HISTORY_STORAGE_BYTECODE = hex"3373fffffffffffffffffffffffffffffffffffffffe14604657602036036042575f35600143038111604257611fff81430311604257611fff9006545f5260205ff35b5f5ffd5b5f35611fff60014303065500"; function setUp() public { From 3184a68e294ab86351cda73dded2e2655f9ea445 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:54:42 -0400 Subject: [PATCH 4/8] fix tests --- test/utils/Blockhash.t.sol | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/test/utils/Blockhash.t.sol b/test/utils/Blockhash.t.sol index af7682d5e9e..87b6efe6bb8 100644 --- a/test/utils/Blockhash.t.sol +++ b/test/utils/Blockhash.t.sol @@ -15,17 +15,19 @@ contract BlockhashTest is Test { hex"3373fffffffffffffffffffffffffffffffffffffffe14604657602036036042575f35600143038111604257611fff81430311604257611fff9006545f5260205ff35b5f5ffd5b5f35611fff60014303065500"; function setUp() public { + vm.roll(block.number + 100); + startingBlock = block.number; vm.etch(Blockhash.HISTORY_STORAGE_ADDRESS, HISTORY_STORAGE_BYTECODE); } - function testFuzzRecentBlocks(uint256 offset, uint256 currentBlock, bytes32 expectedHash) public { + function testFuzzRecentBlocks(uint8 offset, uint64 currentBlock, bytes32 expectedHash) public { // Recent blocks (1-256 blocks old) - offset = bound(offset, 1, 256); - vm.assume(currentBlock > offset); + uint256 boundedOffset = uint256(offset) + 1; + vm.assume(currentBlock > boundedOffset); vm.roll(currentBlock); - uint256 targetBlock = currentBlock - offset; + uint256 targetBlock = currentBlock - boundedOffset; vm.setBlockhash(targetBlock, expectedHash); bytes32 result = Blockhash.blockHash(targetBlock); @@ -33,14 +35,15 @@ contract BlockhashTest is Test { assertEq(result, expectedHash); } - function testFuzzHistoryBlocks(uint256 offset, uint256 currentBlock, bytes32 expectedHash) public { + function testFuzzHistoryBlocks(uint16 offset, uint256 currentBlock, bytes32 expectedHash) public { // History blocks (257-8191 blocks old) - offset = bound(offset, 257, 8191); + offset = uint16(bound(offset, 257, 8191)); vm.assume(currentBlock > offset); vm.roll(currentBlock); - _setHistoryBlockhash(expectedHash); 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)) @@ -74,11 +77,16 @@ contract BlockhashTest is Test { } function _setHistoryBlockhash(bytes32 blockHash) internal { - vm.assume(block.number < type(uint256).max); - vm.roll(block.number + 1); // roll to the next block so the storage contract sets the parent's blockhash + _setHistoryBlockhash(block.number, blockHash); + } + + function _setHistoryBlockhash(uint256 blockNumber, bytes32 blockHash) internal { + uint256 currentBlock = block.number; + 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(block.number - 1); + vm.roll(currentBlock); } } From 92992e89520155564093b7fb36363339f477b6ff Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 23 Apr 2025 16:17:22 -0400 Subject: [PATCH 5/8] fix coverage --- test/utils/Blockhash.t.sol | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/utils/Blockhash.t.sol b/test/utils/Blockhash.t.sol index 87b6efe6bb8..68032f6d7c9 100644 --- a/test/utils/Blockhash.t.sol +++ b/test/utils/Blockhash.t.sol @@ -81,12 +81,13 @@ contract BlockhashTest is Test { } function _setHistoryBlockhash(uint256 blockNumber, bytes32 blockHash) internal { - uint256 currentBlock = block.number; + // 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); + vm.roll(currentBlock + 1); } } From 1f2d0f674c249ae1f2f09255810753ff54b2c55b Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 24 Apr 2025 11:04:12 -0400 Subject: [PATCH 6/8] add test to ensure unsupported chains fail gracefully --- test/utils/Blockhash.t.sol | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/utils/Blockhash.t.sol b/test/utils/Blockhash.t.sol index 68032f6d7c9..60805042d9e 100644 --- a/test/utils/Blockhash.t.sol +++ b/test/utils/Blockhash.t.sol @@ -76,6 +76,13 @@ contract BlockhashTest is Test { } } + 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); } From 1e9510761558083c8085600ffe005a71211b29f5 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:06:19 -0400 Subject: [PATCH 7/8] fix return value on unsupported chains --- contracts/utils/Blockhash.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/contracts/utils/Blockhash.sol b/contracts/utils/Blockhash.sol index bfbbfb83ff1..dbc7b7323a9 100644 --- a/contracts/utils/Blockhash.sol +++ b/contracts/utils/Blockhash.sol @@ -43,9 +43,7 @@ library Blockhash { // 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. let success := staticcall(gas(), HISTORY_STORAGE_ADDRESS, 0, 0x20, 0, 0x20) - - // In case of failure, the returndata might include the revert reason or custom error - if success { + if and(success, gt(returndatasize(), 0)) { hash := mload(0) } } From eb7b88f7e9ca45279b2afafccc58d36d60c2c423 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 24 Apr 2025 17:03:24 -0400 Subject: [PATCH 8/8] add hardhat testing --- hardhat/common-contracts.js | 6 +++ test/utils/Blockhash.test.js | 76 ++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 test/utils/Blockhash.test.js diff --git a/hardhat/common-contracts.js b/hardhat/common-contracts.js index fd4ef1dbad5..67e44be23cd 100644 --- a/hardhat/common-contracts.js +++ b/hardhat/common-contracts.js @@ -47,6 +47,12 @@ const INSTANCES = { bytecode: '0x60003681823780368234f58015156014578182fd5b80825250506014600cf3', }, }, + eip2935: { + address: '0x0000F90827F1C53a10cb7A02335B175320002935', + abi: [], + bytecode: + '0x3373fffffffffffffffffffffffffffffffffffffffe14604657602036036042575f35600143038111604257611fff81430311604257611fff9006545f5260205ff35b5f5ffd5b5f35611fff60014303065500', + }, }; const setup = (input, ethers) => diff --git a/test/utils/Blockhash.test.js b/test/utils/Blockhash.test.js new file mode 100644 index 00000000000..a3de2655e83 --- /dev/null +++ b/test/utils/Blockhash.test.js @@ -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); + }); +});