From c1b71789d2e6ea9fdc03b20dd89cb70090b5ae5c Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Tue, 3 Mar 2026 14:55:09 +0000 Subject: [PATCH 1/5] feat: add FlashblockIndex contract --- src/L2/FlashblockIndex.sol | 45 +++++++++++ test/L2/FlashblockIndex.t.sol | 148 ++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/L2/FlashblockIndex.sol create mode 100644 test/L2/FlashblockIndex.t.sol diff --git a/src/L2/FlashblockIndex.sol b/src/L2/FlashblockIndex.sol new file mode 100644 index 00000000..ab7ce5c2 --- /dev/null +++ b/src/L2/FlashblockIndex.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +/// @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 { + /// @notice Thrown when the caller is not the authorized builder. + error OnlyBuilder(); + + /// @notice Thrown when calldata is not exactly 1 byte. + error InvalidCalldata(); + + /// @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 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])); + } + + /// @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..b047328f --- /dev/null +++ b/test/L2/FlashblockIndex.t.sol @@ -0,0 +1,148 @@ +// 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 { + 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 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 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); + } + + /// @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)); + } +} From 005d64ce01eec4de07538c01252a87747bd606b8 Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Tue, 3 Mar 2026 16:14:36 +0000 Subject: [PATCH 2/5] refactor: make FlashblockIndex upgradeable and emit FlashblockIndexUpdated event --- src/L2/FlashblockIndex.sol | 20 +++++++++++++++++++- test/L2/FlashblockIndex.t.sol | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/L2/FlashblockIndex.sol b/src/L2/FlashblockIndex.sol index ab7ce5c2..eccc5a0c 100644 --- a/src/L2/FlashblockIndex.sol +++ b/src/L2/FlashblockIndex.sol @@ -1,17 +1,30 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.15; +import { Initializable } from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +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 { +contract FlashblockIndex is Initializable, ISemver { /// @notice Thrown when the caller is not the authorized builder. error OnlyBuilder(); /// @notice Thrown when calldata is not exactly 1 byte. error InvalidCalldata(); + /// @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 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; @@ -23,8 +36,12 @@ contract FlashblockIndex { /// @param builder The address authorized to update the flashblock index. constructor(address builder) { BUILDER = builder; + _disableInitializers(); } + /// @notice Initializer. + function initialize() external initializer { } + /// @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. @@ -32,6 +49,7 @@ contract FlashblockIndex { 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. diff --git a/test/L2/FlashblockIndex.t.sol b/test/L2/FlashblockIndex.t.sol index b047328f..1739a49a 100644 --- a/test/L2/FlashblockIndex.t.sol +++ b/test/L2/FlashblockIndex.t.sol @@ -5,6 +5,8 @@ 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; @@ -18,6 +20,17 @@ contract FlashblockIndexTest is Test { assertEq(flashblockIndex.BUILDER(), builder); } + /// @notice Tests that initialize() reverts on the implementation contract since initializers are disabled. + function test_initialize_reverts_whenCalledOnImplementation() external { + vm.expectRevert("Initializable: contract is already initialized"); + flashblockIndex.initialize(); + } + + /// @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(); @@ -71,6 +84,15 @@ contract FlashblockIndexTest is Test { 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; @@ -136,6 +158,8 @@ contract FlashblockIndexTest is Test { 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. From 3d64a2c804bc67000aaeba12e00624bf310fb0c1 Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Thu, 5 Mar 2026 14:25:34 +0000 Subject: [PATCH 3/5] chore: update semver-lock snapshot --- snapshots/semver-lock.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/snapshots/semver-lock.json b/snapshots/semver-lock.json index 5f3043e6..2f9e1219 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": "0x16c2bc1f2e6526eac40de3636464f8f3899f087773cb63106f5ebea38ea2160e", + "sourceCodeHash": "0x71c264e6ef9cbcd9f82dcbddcda6ad8b253ab23b10b317caead4a0d6efa94b2c" + }, "src/L2/GasPriceOracle.sol:GasPriceOracle": { "initCodeHash": "0xf72c23d9c3775afd7b645fde429d09800622d329116feb5ff9829634655123ca", "sourceCodeHash": "0xb4d1bf3669ba87bbeaf4373145c7e1490478c4a05ba4838a524aa6f0ce7348a6" From 3bb0a60bd154eb96282c3c12a6d0e5598e44d8d5 Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Thu, 5 Mar 2026 16:14:29 +0000 Subject: [PATCH 4/5] refactor: remove Initializable from FlashblockIndex --- snapshots/semver-lock.json | 4 ++-- src/L2/FlashblockIndex.sol | 7 +------ test/L2/FlashblockIndex.t.sol | 6 ------ 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/snapshots/semver-lock.json b/snapshots/semver-lock.json index 2f9e1219..4ccf12f0 100644 --- a/snapshots/semver-lock.json +++ b/snapshots/semver-lock.json @@ -68,8 +68,8 @@ "sourceCodeHash": "0x95dd8da08e907fa398c98710bb12fda9fb50d9688c5d2144fd9a424c99e672c5" }, "src/L2/FlashblockIndex.sol:FlashblockIndex": { - "initCodeHash": "0x16c2bc1f2e6526eac40de3636464f8f3899f087773cb63106f5ebea38ea2160e", - "sourceCodeHash": "0x71c264e6ef9cbcd9f82dcbddcda6ad8b253ab23b10b317caead4a0d6efa94b2c" + "initCodeHash": "0x7e09adc445d209875b09e70f0ea025ec45e071f7ca6c1239187f08edc1645b04", + "sourceCodeHash": "0x1bb694586457943252ead036ca5ae9d9fdd65b57e4a964ede6072bc014382a19" }, "src/L2/GasPriceOracle.sol:GasPriceOracle": { "initCodeHash": "0xf72c23d9c3775afd7b645fde429d09800622d329116feb5ff9829634655123ca", diff --git a/src/L2/FlashblockIndex.sol b/src/L2/FlashblockIndex.sol index eccc5a0c..1ea808ed 100644 --- a/src/L2/FlashblockIndex.sol +++ b/src/L2/FlashblockIndex.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.15; -import { Initializable } from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import { ISemver } from "interfaces/universal/ISemver.sol"; /// @custom:upgradeable @@ -9,7 +8,7 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// @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 Initializable, ISemver { +contract FlashblockIndex is ISemver { /// @notice Thrown when the caller is not the authorized builder. error OnlyBuilder(); @@ -36,12 +35,8 @@ contract FlashblockIndex is Initializable, ISemver { /// @param builder The address authorized to update the flashblock index. constructor(address builder) { BUILDER = builder; - _disableInitializers(); } - /// @notice Initializer. - function initialize() external initializer { } - /// @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. diff --git a/test/L2/FlashblockIndex.t.sol b/test/L2/FlashblockIndex.t.sol index 1739a49a..71e29be3 100644 --- a/test/L2/FlashblockIndex.t.sol +++ b/test/L2/FlashblockIndex.t.sol @@ -20,12 +20,6 @@ contract FlashblockIndexTest is Test { assertEq(flashblockIndex.BUILDER(), builder); } - /// @notice Tests that initialize() reverts on the implementation contract since initializers are disabled. - function test_initialize_reverts_whenCalledOnImplementation() external { - vm.expectRevert("Initializable: contract is already initialized"); - flashblockIndex.initialize(); - } - /// @notice Tests that version() returns "1.0.0". function test_version_returnsCorrectValue() external view { assertEq(flashblockIndex.version(), "1.0.0"); From 7ef587b9f050db4b27babc90175b06a148f65344 Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Thu, 5 Mar 2026 16:36:19 +0000 Subject: [PATCH 5/5] refactor: reorder declarations per style guide --- snapshots/semver-lock.json | 2 +- src/L2/FlashblockIndex.sol | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/snapshots/semver-lock.json b/snapshots/semver-lock.json index 4ccf12f0..a727c77c 100644 --- a/snapshots/semver-lock.json +++ b/snapshots/semver-lock.json @@ -69,7 +69,7 @@ }, "src/L2/FlashblockIndex.sol:FlashblockIndex": { "initCodeHash": "0x7e09adc445d209875b09e70f0ea025ec45e071f7ca6c1239187f08edc1645b04", - "sourceCodeHash": "0x1bb694586457943252ead036ca5ae9d9fdd65b57e4a964ede6072bc014382a19" + "sourceCodeHash": "0x7e52ea8b5725107344b51b89a365de33d0c8d158a3c32b539298b707149d9787" }, "src/L2/GasPriceOracle.sol:GasPriceOracle": { "initCodeHash": "0xf72c23d9c3775afd7b645fde429d09800622d329116feb5ff9829634655123ca", diff --git a/src/L2/FlashblockIndex.sol b/src/L2/FlashblockIndex.sol index 1ea808ed..0b8ecf63 100644 --- a/src/L2/FlashblockIndex.sol +++ b/src/L2/FlashblockIndex.sol @@ -9,17 +9,6 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// @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 Thrown when the caller is not the authorized builder. - error OnlyBuilder(); - - /// @notice Thrown when calldata is not exactly 1 byte. - error InvalidCalldata(); - - /// @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 Semantic version. /// @custom:semver 1.0.0 string public constant override version = "1.0.0"; @@ -31,6 +20,17 @@ contract FlashblockIndex is ISemver { /// @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) {