diff --git a/snapshots/semver-lock.json b/snapshots/semver-lock.json index 5f3043e6..a727c77c 100644 --- a/snapshots/semver-lock.json +++ b/snapshots/semver-lock.json @@ -67,6 +67,10 @@ "initCodeHash": "0xdaae3903628f760e36da47c8f8d75d20962d1811fb5129cb09eb01803e67c095", "sourceCodeHash": "0x95dd8da08e907fa398c98710bb12fda9fb50d9688c5d2144fd9a424c99e672c5" }, + "src/L2/FlashblockIndex.sol:FlashblockIndex": { + "initCodeHash": "0x7e09adc445d209875b09e70f0ea025ec45e071f7ca6c1239187f08edc1645b04", + "sourceCodeHash": "0x7e52ea8b5725107344b51b89a365de33d0c8d158a3c32b539298b707149d9787" + }, "src/L2/GasPriceOracle.sol:GasPriceOracle": { "initCodeHash": "0xf72c23d9c3775afd7b645fde429d09800622d329116feb5ff9829634655123ca", "sourceCodeHash": "0xb4d1bf3669ba87bbeaf4373145c7e1490478c4a05ba4838a524aa6f0ce7348a6" diff --git a/src/L2/FlashblockIndex.sol b/src/L2/FlashblockIndex.sol new file mode 100644 index 00000000..0b8ecf63 --- /dev/null +++ b/src/L2/FlashblockIndex.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { ISemver } from "interfaces/universal/ISemver.sol"; + +/// @custom:upgradeable +/// @title FlashblockIndex +/// @notice Stores the current flashblock index alongside block.number. +/// @dev The builder calls this via fallback with 1 byte of calldata (the index as uint8). +/// Both values are manually packed into a single uint256 to guarantee 1 SSTORE per write. +contract FlashblockIndex is ISemver { + /// @notice Semantic version. + /// @custom:semver 1.0.0 + string public constant override version = "1.0.0"; + + /// @notice The authorized builder address, set at deploy time. + address public immutable BUILDER; + + /// @notice Packed storage: blockNumber (uint48) in bits [55:8] | flashblockIndex (uint8) in bits [7:0]. + /// @dev Using uint48 for block numbers is safe for the foreseeable future (~281 trillion blocks). + uint256 private _packed; + + /// @notice Emitted when the flashblock index is updated. + /// @param flashblockIndex The new flashblock index. + /// @param blockNumber The block number at which the index was set. + event FlashblockIndexUpdated(uint8 indexed flashblockIndex, uint48 indexed blockNumber); + + /// @notice Thrown when the caller is not the authorized builder. + error OnlyBuilder(); + + /// @notice Thrown when calldata is not exactly 1 byte. + error InvalidCalldata(); + + /// @notice Constructor. + /// @param builder The address authorized to update the flashblock index. + constructor(address builder) { + BUILDER = builder; + } + + /// @notice Sets the flashblock index for the current block. + /// @dev Calldata must be exactly 1 byte representing the flashblock index (uint8). + /// Stores `(block.number << 8) | index` in a single SSTORE. + fallback() external { + if (msg.sender != BUILDER) revert OnlyBuilder(); + if (msg.data.length != 1) revert InvalidCalldata(); + _packed = (uint256(uint48(block.number)) << 8) | uint256(uint8(msg.data[0])); + emit FlashblockIndexUpdated(uint8(msg.data[0]), uint48(block.number)); + } + + /// @notice Returns the last stored flashblock index and its associated block number. + /// @return flashblockIndex The flashblock index. + /// @return blockNumber The block number at which the index was set. + function get() external view returns (uint8 flashblockIndex, uint48 blockNumber) { + uint256 packed = _packed; + flashblockIndex = uint8(packed); + blockNumber = uint48(packed >> 8); + } +} diff --git a/test/L2/FlashblockIndex.t.sol b/test/L2/FlashblockIndex.t.sol new file mode 100644 index 00000000..71e29be3 --- /dev/null +++ b/test/L2/FlashblockIndex.t.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { Test } from "forge-std/Test.sol"; +import { FlashblockIndex } from "src/L2/FlashblockIndex.sol"; + +contract FlashblockIndexTest is Test { + event FlashblockIndexUpdated(uint8 indexed flashblockIndex, uint48 indexed blockNumber); + + FlashblockIndex flashblockIndex; + address builder; + + function setUp() public { + builder = makeAddr("builder"); + flashblockIndex = new FlashblockIndex(builder); + } + + /// @notice Tests that the constructor correctly sets the BUILDER immutable. + function test_constructor_setsBuilder() external view { + assertEq(flashblockIndex.BUILDER(), builder); + } + + /// @notice Tests that version() returns "1.0.0". + function test_version_returnsCorrectValue() external view { + assertEq(flashblockIndex.version(), "1.0.0"); + } + + /// @notice Tests that get() returns (0, 0) when no index has ever been written. + function test_get_returnsZeros_whenNeverWritten() external view { + (uint8 index, uint48 blockNumber) = flashblockIndex.get(); + assertEq(index, 0); + assertEq(blockNumber, 0); + } + + /// @notice Tests that get() returns the correct index and block number after a write. + function test_get_returnsCorrectValues(uint8 index, uint48 blockNumber) external { + vm.roll(blockNumber); + (bool success,) = _callFallback({ caller: builder, index: index }); + assertTrue(success); + + (uint8 actualIndex, uint48 actualBlock) = flashblockIndex.get(); + assertEq(actualIndex, index); + assertEq(actualBlock, blockNumber); + } + + /// @notice Tests that the fallback reverts with OnlyBuilder when called by a non-builder address. + function test_fallback_reverts_whenCallerIsNotBuilder(address caller, uint8 index) external { + vm.assume(caller != builder); + (bool success, bytes memory returnData) = _callFallback({ caller: caller, index: index }); + assertFalse(success); + assertEq(bytes4(returnData), FlashblockIndex.OnlyBuilder.selector); + } + + /// @notice Tests that the fallback reverts with InvalidCalldata when called with zero bytes. + function test_fallback_reverts_whenCalldataIsEmpty() external { + vm.prank(builder); + (bool success, bytes memory returnData) = address(flashblockIndex).call(""); + assertFalse(success); + assertEq(bytes4(returnData), FlashblockIndex.InvalidCalldata.selector); + } + + /// @notice Tests that the fallback reverts with InvalidCalldata when called with more than 1 byte. + function test_fallback_reverts_whenCalldataIsTooLong(uint8 extra) external { + vm.prank(builder); + (bool success, bytes memory returnData) = address(flashblockIndex).call(abi.encodePacked(uint8(1), extra)); + assertFalse(success); + assertEq(bytes4(returnData), FlashblockIndex.InvalidCalldata.selector); + } + + /// @notice Tests that the fallback stores the index and block number correctly when called by the builder. + function test_fallback_setsIndex(uint8 index, uint48 blockNumber) external { + vm.roll(blockNumber); + (bool success,) = _callFallback({ caller: builder, index: index }); + assertTrue(success); + + (uint8 actualIndex, uint48 actualBlock) = flashblockIndex.get(); + assertEq(actualIndex, index); + assertEq(actualBlock, blockNumber); + } + + /// @notice Tests that the fallback emits FlashblockIndexUpdated with the correct parameters. + function test_fallback_emitsFlashblockIndexUpdated(uint8 index, uint48 blockNumber) external { + vm.roll(blockNumber); + vm.expectEmit(address(flashblockIndex)); + emit FlashblockIndexUpdated(index, blockNumber); + (bool success,) = _callFallback({ caller: builder, index: index }); + assertTrue(success); + } + + /// @notice Tests that a second fallback call overwrites the previous value at a different block. + function test_fallback_overwritesPreviousValue() external { + uint48 firstBlock = 100; + uint8 firstIndex = 5; + vm.roll(firstBlock); + (bool s1,) = _callFallback({ caller: builder, index: firstIndex }); + assertTrue(s1); + + uint48 secondBlock = 200; + uint8 secondIndex = 10; + vm.roll(secondBlock); + (bool s2,) = _callFallback({ caller: builder, index: secondIndex }); + assertTrue(s2); + + (uint8 actualIndex, uint48 actualBlock) = flashblockIndex.get(); + assertEq(actualIndex, secondIndex); + assertEq(actualBlock, secondBlock); + } + + /// @notice Tests that a second fallback call overwrites the previous value within the same block. + function test_fallback_overwritesWithinSameBlock() external { + uint48 blockNumber = 100; + uint8 firstIndex = 5; + uint8 secondIndex = 10; + + vm.roll(blockNumber); + (bool s1,) = _callFallback({ caller: builder, index: firstIndex }); + assertTrue(s1); + + (bool s2,) = _callFallback({ caller: builder, index: secondIndex }); + assertTrue(s2); + + (uint8 actualIndex, uint48 actualBlock) = flashblockIndex.get(); + assertEq(actualIndex, secondIndex); + assertEq(actualBlock, blockNumber); + } + + /// @notice Tests that the fallback correctly stores the maximum uint48 block number. + function test_fallback_storesMaxBlockNumber() external { + uint48 maxBlock = type(uint48).max; + uint8 index = 1; + + vm.roll(maxBlock); + (bool success,) = _callFallback({ caller: builder, index: index }); + assertTrue(success); + + (uint8 actualIndex, uint48 actualBlock) = flashblockIndex.get(); + assertEq(actualIndex, index); + assertEq(actualBlock, maxBlock); + } + + /// @notice Tests that the block number is truncated to uint48 when it exceeds the max value. + function test_fallback_truncatesBlockNumber() external { + uint256 overflowBlock = uint256(type(uint48).max) + 1; + uint8 index = 1; + + vm.roll(overflowBlock); + (bool success,) = _callFallback({ caller: builder, index: index }); + assertTrue(success); + + (uint8 actualIndex, uint48 actualBlock) = flashblockIndex.get(); + assertEq(actualIndex, index); + assertEq(actualBlock, 0); + } + + // --- Helper --- + + /// @notice Helper function to call the fallback with the given caller and index. + /// @param caller The address of the caller. + /// @param index The index to call the fallback with. + /// @return success True if the fallback call succeeded. + /// @return returnData The return data from the fallback call. + function _callFallback(address caller, uint8 index) private returns (bool success, bytes memory returnData) { + vm.prank(caller); + (success, returnData) = address(flashblockIndex).call(abi.encodePacked(index)); + } +}