From 6ab96c3df5980a8b471b547d51be62c4e19715b6 Mon Sep 17 00:00:00 2001 From: TucksonDev Date: Tue, 10 Mar 2026 15:26:14 +0000 Subject: [PATCH 1/2] Add BaseFeeManager contract --- src/chain/BaseFeeManager.sol | 70 +++++++++ test/foundry/BaseFeeManager.t.sol | 253 ++++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 src/chain/BaseFeeManager.sol create mode 100644 test/foundry/BaseFeeManager.t.sol diff --git a/src/chain/BaseFeeManager.sol b/src/chain/BaseFeeManager.sol new file mode 100644 index 00000000..da6e8dff --- /dev/null +++ b/src/chain/BaseFeeManager.sol @@ -0,0 +1,70 @@ +// Copyright 2022-2025, Offchain Labs, Inc. +// For license information, see https://github.com/nitro/blob/master/LICENSE +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity ^0.8.0; + +import "../precompiles/ArbOwner.sol"; +import "../precompiles/ArbGasInfo.sol"; +import "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; + +contract BaseFeeManager is AccessControlEnumerable { + ArbOwner internal constant ARB_OWNER = ArbOwner(address(0x70)); + ArbGasInfo internal constant ARB_GAS_INFO = ArbGasInfo(address(0x6c)); + bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE"); + + uint256 public constant MIN_BASE_FEE_WEI = 0.01 gwei; + uint256 public constant MAX_BASE_FEE_WEI = 0.1 gwei; + + uint256 public expiryTimestamp; + + error InvalidBaseFee(uint256 newL2BaseFee); + error BaseFeeBelowMinimum(uint256 newL2BaseFee, uint256 minimumBaseFee); + error NotExpired(); + + constructor(address admin, address manager, uint256 _expiryTimestamp) { + _setupRole(DEFAULT_ADMIN_ROLE, admin); + _setupRole(MANAGER_ROLE, manager); + expiryTimestamp = _expiryTimestamp; + } + + /// @notice Removes the contract from the list of chain owners after the expiry timestamp + function revoke() external { + if (block.timestamp < expiryTimestamp) { + revert NotExpired(); + } + ARB_OWNER.removeChainOwner(address(this)); + } + + /// @notice Sets the L2 base fee within the allowed range, and above the minimum base fee + /// @param newL2BaseFeeInWei The new L2 base fee to set (in wei) + function setL2BaseFee( + uint256 newL2BaseFeeInWei + ) external onlyRole(MANAGER_ROLE) { + if (newL2BaseFeeInWei < MIN_BASE_FEE_WEI || newL2BaseFeeInWei > MAX_BASE_FEE_WEI) { + revert InvalidBaseFee(newL2BaseFeeInWei); + } + + uint256 minimumL2BaseFee = ARB_GAS_INFO.getMinimumGasPrice(); + if (newL2BaseFeeInWei < minimumL2BaseFee) { + revert BaseFeeBelowMinimum(newL2BaseFeeInWei, minimumL2BaseFee); + } + + ARB_OWNER.setL2BaseFee(newL2BaseFeeInWei); + } + + /// @notice Sets the minimum L2 base fee within the allowed range + /// @param newMinimumL2BaseFeeInWei The new minimum L2 base fee to set (in wei) + function setMinimumL2BaseFee( + uint256 newMinimumL2BaseFeeInWei + ) external onlyRole(MANAGER_ROLE) { + if ( + newMinimumL2BaseFeeInWei < MIN_BASE_FEE_WEI + || newMinimumL2BaseFeeInWei > MAX_BASE_FEE_WEI + ) { + revert InvalidBaseFee(newMinimumL2BaseFeeInWei); + } + + ARB_OWNER.setMinimumL2BaseFee(newMinimumL2BaseFeeInWei); + } +} diff --git a/test/foundry/BaseFeeManager.t.sol b/test/foundry/BaseFeeManager.t.sol new file mode 100644 index 00000000..22435160 --- /dev/null +++ b/test/foundry/BaseFeeManager.t.sol @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +import "forge-std/Test.sol"; +import "../../src/chain/BaseFeeManager.sol"; + +// We need this library to storage the addresses of the mocked contracts, +// since they have to be available in both BaseFeeManagerTest and the mock contracts +library AddressStore { + address constant StorageContractAddress = address(0xabcdef); + address constant ArbOwnerAddress = address(0x70); + address constant ArbGasInfoAddress = address(0x6c); +} + +contract BaseFeeManagerTest is Test { + ArbOwnerMock internal constant ARB_OWNER = ArbOwnerMock(AddressStore.ArbOwnerAddress); + ArbGasInfoMock internal constant ARB_GAS_INFO = ArbGasInfoMock(AddressStore.ArbGasInfoAddress); + + address constant admin = address(1337); + address constant manager = address(7331); + uint256 constant expiryTimestamp = 12345678; + + BaseFeeManager public baseFeeManager; + + constructor() { + baseFeeManager = new BaseFeeManager(admin, manager, expiryTimestamp); + vm.etch(AddressStore.StorageContractAddress, type(StorageContractMock).runtimeCode); + vm.etch(AddressStore.ArbOwnerAddress, type(ArbOwnerMock).runtimeCode); + vm.etch(AddressStore.ArbGasInfoAddress, type(ArbGasInfoMock).runtimeCode); + } + + function test_revoke() external { + // Test before expiry + vm.warp(expiryTimestamp - 1); + vm.expectRevert(BaseFeeManager.NotExpired.selector); + baseFeeManager.revoke(); + + // Test after expiry + vm.warp(expiryTimestamp); + assertFalse(ARB_OWNER.removeChainOwnerCalled()); + baseFeeManager.revoke(); + assertTrue(ARB_OWNER.removeChainOwnerCalled()); + } + + // + // --- setL2BaseFee tests --- + // + function test_setL2BaseFee_success() external { + // Set the on-chain minimum low so the cross-validation passes + ARB_OWNER.setMinimumL2BaseFee(0.01 gwei); + + // Test at minimum + vm.prank(manager); + baseFeeManager.setL2BaseFee(0.01 gwei); + + // Test at maximum + vm.prank(manager); + baseFeeManager.setL2BaseFee(0.1 gwei); + + // Test in the middle + vm.prank(manager); + baseFeeManager.setL2BaseFee(0.05 gwei); + } + + function test_setL2BaseFee_accessControl() external { + // Test non-manager cannot call + vm.expectRevert(); + baseFeeManager.setL2BaseFee(0.05 gwei); + + // Test admin without manager role cannot call + vm.prank(admin); + vm.expectRevert(); + baseFeeManager.setL2BaseFee(0.05 gwei); + + // Test manager can call + vm.prank(manager); + baseFeeManager.setL2BaseFee(0.05 gwei); + } + + function test_setL2BaseFee_invalidBaseFee() external { + // Test below minimum (0.01 gwei - 1) + vm.prank(manager); + vm.expectRevert( + abi.encodeWithSelector(BaseFeeManager.InvalidBaseFee.selector, 0.01 gwei - 1) + ); + baseFeeManager.setL2BaseFee(0.01 gwei - 1); + + // Test above maximum (0.1 gwei + 1) + vm.prank(manager); + vm.expectRevert( + abi.encodeWithSelector(BaseFeeManager.InvalidBaseFee.selector, 0.1 gwei + 1) + ); + baseFeeManager.setL2BaseFee(0.1 gwei + 1); + } + + function test_setL2BaseFee_belowMinimumBaseFee() external { + // Set the on-chain minimum to 0.05 gwei + ARB_OWNER.setMinimumL2BaseFee(0.05 gwei); + + // Setting base fee at the minimum should succeed + vm.prank(manager); + baseFeeManager.setL2BaseFee(0.05 gwei); + + // Setting base fee above the minimum should succeed + vm.prank(manager); + baseFeeManager.setL2BaseFee(0.06 gwei); + + // Setting base fee below the on-chain minimum should revert + vm.prank(manager); + vm.expectRevert( + abi.encodeWithSelector( + BaseFeeManager.BaseFeeBelowMinimum.selector, 0.04 gwei, 0.05 gwei + ) + ); + baseFeeManager.setL2BaseFee(0.04 gwei); + } + + // + // --- setMinimumL2BaseFee tests --- + // + function test_setMinimumL2BaseFee_success() external { + // Test at minimum + vm.prank(manager); + baseFeeManager.setMinimumL2BaseFee(0.01 gwei); + + // Test at maximum + vm.prank(manager); + baseFeeManager.setMinimumL2BaseFee(0.1 gwei); + + // Test in the middle + vm.prank(manager); + baseFeeManager.setMinimumL2BaseFee(0.05 gwei); + } + + function test_setMinimumL2BaseFee_accessControl() external { + // Test non-manager cannot call + vm.expectRevert(); + baseFeeManager.setMinimumL2BaseFee(0.05 gwei); + + // Test admin without manager role cannot call + vm.prank(admin); + vm.expectRevert(); + baseFeeManager.setMinimumL2BaseFee(0.05 gwei); + + // Test manager can call + vm.prank(manager); + baseFeeManager.setMinimumL2BaseFee(0.05 gwei); + } + + function test_setMinimumL2BaseFee_invalidBaseFee() external { + // Test below minimum (0.01 gwei - 1) + vm.prank(manager); + vm.expectRevert( + abi.encodeWithSelector(BaseFeeManager.InvalidBaseFee.selector, 0.01 gwei - 1) + ); + baseFeeManager.setMinimumL2BaseFee(0.01 gwei - 1); + + // Test above maximum (0.1 gwei + 1) + vm.prank(manager); + vm.expectRevert( + abi.encodeWithSelector(BaseFeeManager.InvalidBaseFee.selector, 0.1 gwei + 1) + ); + baseFeeManager.setMinimumL2BaseFee(0.1 gwei + 1); + } + + function test_setMinimumL2BaseFee_updatesMinimumForBaseFeeCheck() external { + // Set a new minimum via the manager contract + vm.prank(manager); + baseFeeManager.setMinimumL2BaseFee(0.08 gwei); + + // Confirm the minimum was updated in the shared storage + assertEq(ARB_GAS_INFO.getMinimumGasPrice(), 0.08 gwei); + + // Setting base fee below the new minimum should revert + vm.prank(manager); + vm.expectRevert( + abi.encodeWithSelector( + BaseFeeManager.BaseFeeBelowMinimum.selector, 0.07 gwei, 0.08 gwei + ) + ); + baseFeeManager.setL2BaseFee(0.07 gwei); + + // Setting base fee at the new minimum should succeed + vm.prank(manager); + baseFeeManager.setL2BaseFee(0.08 gwei); + } +} + +// Since ArbOwner and ArbGasInfo access the same shared database, we create a mock storage contract to hold that storage and access it +// from forwarded calls in ArbOwnerMock and ArbGasInfoMock. +contract StorageContractMock { + uint256 public l2BaseFee; + uint256 public minimumL2BaseFee; + + constructor() { + l2BaseFee = 0.02 gwei; + minimumL2BaseFee = 0.02 gwei; + } + + function setL2BaseFee( + uint256 priceInWei + ) external { + l2BaseFee = priceInWei; + } + + function setMinimumL2BaseFee( + uint256 priceInWei + ) external { + minimumL2BaseFee = priceInWei; + } + + function getMinimumGasPrice() external view returns (uint256) { + return minimumL2BaseFee; + } +} + +contract ArbGasInfoMock { + StorageContractMock internal constant STORAGE = + StorageContractMock(AddressStore.StorageContractAddress); + + function getMinimumGasPrice() external view returns (uint256) { + return STORAGE.getMinimumGasPrice(); + } +} + +contract ArbOwnerMock { + StorageContractMock internal constant STORAGE = + StorageContractMock(AddressStore.StorageContractAddress); + + bool public removeChainOwnerCalled; + + function removeChainOwner( + address + ) external { + removeChainOwnerCalled = true; + } + + function setL2BaseFee( + uint256 priceInWei + ) external { + STORAGE.setL2BaseFee(priceInWei); + } + + function setMinimumL2BaseFee( + uint256 priceInWei + ) external { + STORAGE.setMinimumL2BaseFee(priceInWei); + } + + function getMinimumGasPrice() external view returns (uint256) { + return STORAGE.getMinimumGasPrice(); + } +} From 57e5b37a6cb704ed80150e6aa6938d5375a0d7dc Mon Sep 17 00:00:00 2001 From: TucksonDev Date: Thu, 19 Mar 2026 09:20:11 +0000 Subject: [PATCH 2/2] Add expiryTime to NotExpired error --- src/chain/BaseFeeManager.sol | 8 ++++---- test/foundry/BaseFeeManager.t.sol | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/chain/BaseFeeManager.sol b/src/chain/BaseFeeManager.sol index da6e8dff..039a8e5d 100644 --- a/src/chain/BaseFeeManager.sol +++ b/src/chain/BaseFeeManager.sol @@ -1,4 +1,4 @@ -// Copyright 2022-2025, Offchain Labs, Inc. +// Copyright 2022-2026, Offchain Labs, Inc. // For license information, see https://github.com/nitro/blob/master/LICENSE // SPDX-License-Identifier: BUSL-1.1 @@ -16,11 +16,11 @@ contract BaseFeeManager is AccessControlEnumerable { uint256 public constant MIN_BASE_FEE_WEI = 0.01 gwei; uint256 public constant MAX_BASE_FEE_WEI = 0.1 gwei; - uint256 public expiryTimestamp; + uint256 public immutable expiryTimestamp; error InvalidBaseFee(uint256 newL2BaseFee); error BaseFeeBelowMinimum(uint256 newL2BaseFee, uint256 minimumBaseFee); - error NotExpired(); + error NotExpired(uint256 expiryTimestamp); constructor(address admin, address manager, uint256 _expiryTimestamp) { _setupRole(DEFAULT_ADMIN_ROLE, admin); @@ -31,7 +31,7 @@ contract BaseFeeManager is AccessControlEnumerable { /// @notice Removes the contract from the list of chain owners after the expiry timestamp function revoke() external { if (block.timestamp < expiryTimestamp) { - revert NotExpired(); + revert NotExpired(expiryTimestamp); } ARB_OWNER.removeChainOwner(address(this)); } diff --git a/test/foundry/BaseFeeManager.t.sol b/test/foundry/BaseFeeManager.t.sol index 22435160..98f58de5 100644 --- a/test/foundry/BaseFeeManager.t.sol +++ b/test/foundry/BaseFeeManager.t.sol @@ -32,7 +32,9 @@ contract BaseFeeManagerTest is Test { function test_revoke() external { // Test before expiry vm.warp(expiryTimestamp - 1); - vm.expectRevert(BaseFeeManager.NotExpired.selector); + vm.expectRevert( + abi.encodeWithSelector(BaseFeeManager.NotExpired.selector, expiryTimestamp) + ); baseFeeManager.revoke(); // Test after expiry