From ad1e9fd581a3ed9350c38c7159404787cb728e76 Mon Sep 17 00:00:00 2001 From: Derek Guenther Date: Wed, 12 Aug 2026 18:37:13 -0400 Subject: [PATCH 1/3] feat(L1): make deposit resource minimum base fee deploy-configurable Allow L3 deployments to align SystemConfig minimumBaseFee and portal prevBaseFee with the 0.01 gwei conversion floor so standard deposits stay under Base's per-transaction gas cap during low parent demand. Co-Authored-By: Claude --- scripts/deploy/DeployConfig.s.sol | 2 + scripts/deploy/SystemDeploy.s.sol | 11 +- scripts/libraries/Types.sol | 1 + snapshots/semver-lock.json | 2 +- src/L1/ResourceMetering.sol | 5 +- src/libraries/Constants.sol | 11 +- test/L1/ResourceMetering.t.sol | 8 +- test/deploy/DeployConfig.t.sol | 63 ++++++++ .../deploy/ResourceConfigMinimumBaseFee.t.sol | 134 ++++++++++++++++++ test/deploy/SystemDeploy.t.sol | 6 +- 10 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 test/deploy/DeployConfig.t.sol create mode 100644 test/deploy/ResourceConfigMinimumBaseFee.t.sol diff --git a/scripts/deploy/DeployConfig.s.sol b/scripts/deploy/DeployConfig.s.sol index a4cb2971b..02c1b6c58 100644 --- a/scripts/deploy/DeployConfig.s.sol +++ b/scripts/deploy/DeployConfig.s.sol @@ -37,6 +37,7 @@ contract DeployConfig is Script { uint32 public basefeeScalar; uint32 public blobbasefeeScalar; + uint32 public resourceConfigMinimumBaseFee; string public saltMixer; @@ -98,6 +99,7 @@ contract DeployConfig is Script { basefeeScalar = uint32(_json.readUint("$.gasPriceOracleBaseFeeScalar")); blobbasefeeScalar = uint32(_json.readUint("$.gasPriceOracleBlobBaseFeeScalar")); + resourceConfigMinimumBaseFee = uint32(_json.readUintOr("$.resourceConfigMinimumBaseFee", 1 gwei)); baseFeeVaultMinimumWithdrawalAmount = _json.readUint("$.baseFeeVaultMinimumWithdrawalAmount"); baseFeeVaultWithdrawalNetwork = _json.readUint("$.baseFeeVaultWithdrawalNetwork"); diff --git a/scripts/deploy/SystemDeploy.s.sol b/scripts/deploy/SystemDeploy.s.sol index d458d2835..af7a7c5f9 100644 --- a/scripts/deploy/SystemDeploy.s.sol +++ b/scripts/deploy/SystemDeploy.s.sol @@ -19,6 +19,7 @@ import { IOptimismPortal2 as IOptimismPortal } from "interfaces/L1/IOptimismPort import { IProtocolVersions } from "interfaces/L1/IProtocolVersions.sol"; import { ISuperchainConfig } from "interfaces/L1/ISuperchainConfig.sol"; import { ISystemConfig } from "interfaces/L1/ISystemConfig.sol"; +import { IResourceMetering } from "interfaces/L1/IResourceMetering.sol"; import { IAddressManager } from "interfaces/legacy/IAddressManager.sol"; import { IL1ChugSplashProxy } from "interfaces/legacy/IL1ChugSplashProxy.sol"; import { IResolvedDelegateProxy } from "interfaces/legacy/IResolvedDelegateProxy.sol"; @@ -395,7 +396,8 @@ contract SystemDeploy is Script { root: Hash.wrap(cfg.multiproofGenesisOutputRoot()), l2SequenceNumber: cfg.multiproofGenesisBlockNumber() }), saltMixer: cfg.saltMixer(), - gasLimit: uint64(cfg.l2GenesisBlockGasLimit()) + gasLimit: uint64(cfg.l2GenesisBlockGasLimit()), + resourceConfigMinimumBaseFee: cfg.resourceConfigMinimumBaseFee() }); } @@ -842,6 +844,11 @@ contract SystemDeploy is Script { delayedWETH: address(_output.delayedWETHProxy) }); + uint32 minimumBaseFee = + _input.resourceConfigMinimumBaseFee == 0 ? uint32(1 gwei) : _input.resourceConfigMinimumBaseFee; + IResourceMetering.ResourceConfig memory resourceConfig = + Constants.resourceConfigWithMinimumBaseFee(minimumBaseFee); + return abi.encodeCall( ISystemConfig.initialize, ( @@ -851,7 +858,7 @@ contract SystemDeploy is Script { bytes32(uint256(uint160(_input.roles.batcher))), _input.gasLimit, _input.roles.unsafeBlockSigner, - Constants.DEFAULT_RESOURCE_CONFIG(), + resourceConfig, Types.chainIdToBatchInboxAddress(_input.l2ChainId), opChainAddrs, _input.l2ChainId, diff --git a/scripts/libraries/Types.sol b/scripts/libraries/Types.sol index 941784952..f4a2bb84e 100644 --- a/scripts/libraries/Types.sol +++ b/scripts/libraries/Types.sol @@ -40,6 +40,7 @@ library Types { Proposal startingAnchorRoot; string saltMixer; uint64 gasLimit; + uint32 resourceConfigMinimumBaseFee; } /// @notice The full set of outputs from deploying a new OP Stack chain. diff --git a/snapshots/semver-lock.json b/snapshots/semver-lock.json index b69767ad8..4da0be7a0 100644 --- a/snapshots/semver-lock.json +++ b/snapshots/semver-lock.json @@ -12,7 +12,7 @@ "sourceCodeHash": "0xd9f576a79e97bc541b3d7a2ee928f34223edaaecb074eeb9e6e2ecee857ce6a0" }, "src/L1/OptimismPortal2.sol:OptimismPortal2": { - "initCodeHash": "0xdaeac3fae27dc1c5924100d06eb337c60010d88b638d6703c5ec25d750810495", + "initCodeHash": "0x3223d48d63cc9e4a6796f2eb343c82c88c1add5289fa66ab72fa2fa4d83d0790", "sourceCodeHash": "0x15cef97e2598ac2ed83fd8662c2f31e61a33e89d9f618b97fdeaa142cf6f9262" }, "src/L1/ProtocolVersions.sol:ProtocolVersions": { diff --git a/src/L1/ResourceMetering.sol b/src/L1/ResourceMetering.sol index 3c963e4eb..03862994d 100644 --- a/src/L1/ResourceMetering.sol +++ b/src/L1/ResourceMetering.sol @@ -167,7 +167,10 @@ abstract contract ResourceMetering is Initializable { /// child contract. function __ResourceMetering_init() internal onlyInitializing { if (params.prevBlockNum == 0) { - params = ResourceParams({ prevBaseFee: 1 gwei, prevBoughtGas: 0, prevBlockNum: uint64(block.number) }); + ResourceConfig memory config = _resourceConfig(); + params = ResourceParams({ + prevBaseFee: config.minimumBaseFee, prevBoughtGas: 0, prevBlockNum: uint64(block.number) + }); } } } diff --git a/src/libraries/Constants.sol b/src/libraries/Constants.sol index 50ad71669..57c85abe1 100644 --- a/src/libraries/Constants.sol +++ b/src/libraries/Constants.sol @@ -45,11 +45,20 @@ library Constants { /// @notice Returns the default values for the ResourceConfig. These are the recommended values /// for a production network. function DEFAULT_RESOURCE_CONFIG() internal pure returns (IResourceMetering.ResourceConfig memory) { + return resourceConfigWithMinimumBaseFee(1 gwei); + } + + /// @notice Returns the default resource config with a custom minimum base fee. + function resourceConfigWithMinimumBaseFee(uint32 _minimumBaseFee) + internal + pure + returns (IResourceMetering.ResourceConfig memory) + { IResourceMetering.ResourceConfig memory config = IResourceMetering.ResourceConfig({ maxResourceLimit: 20_000_000, elasticityMultiplier: 10, baseFeeMaxChangeDenominator: 8, - minimumBaseFee: 1 gwei, + minimumBaseFee: _minimumBaseFee, systemTxMaxGas: 1_000_000, maximumBaseFee: type(uint128).max }); diff --git a/test/L1/ResourceMetering.t.sol b/test/L1/ResourceMetering.t.sol index 6c35cb66d..191d6f49f 100644 --- a/test/L1/ResourceMetering.t.sol +++ b/test/L1/ResourceMetering.t.sol @@ -21,8 +21,8 @@ contract MeterUser is ResourceMetering { ResourceMetering.ResourceConfig public innerConfig; constructor() { - initialize(); innerConfig = defaultResourceConfig(); + initialize(); } function initialize() public initializer { @@ -87,6 +87,12 @@ contract ResourceMetering_Metered_Test is ResourceMetering_TestInit { assertEq(postBlockNum, prevBlockNum); } + /// @notice Tests that initialization sets prevBaseFee to the configured minimum base fee. + function test_initialize_prevBaseFee_matchesMinimumBaseFee_succeeds() external view { + (uint128 prevBaseFee,,) = meter.params(); + assertEq(prevBaseFee, meter.resourceConfig().minimumBaseFee); + } + /// @notice Tests that updating after multiple empty blocks maintains correct base fee. function testFuzz_metered_emptyBlocks_succeeds(uint256 _blockDiff) external { _blockDiff = bound(_blockDiff, 1, 100); diff --git a/test/deploy/DeployConfig.t.sol b/test/deploy/DeployConfig.t.sol new file mode 100644 index 000000000..5e21f80fa --- /dev/null +++ b/test/deploy/DeployConfig.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { Test } from "lib/forge-std/src/Test.sol"; + +import { DeployConfig } from "scripts/deploy/DeployConfig.s.sol"; + +/// @title DeployConfig_ResourceConfigMinimumBaseFee_Test +/// @notice Covers DeployConfig JSON parsing for the optional resource minimum base fee. +contract DeployConfig_ResourceConfigMinimumBaseFee_Test is Test { + DeployConfig internal config; + string internal localConfigPath; + string internal overrideConfigPath; + + function setUp() public { + config = new DeployConfig(); + localConfigPath = string.concat(vm.projectRoot(), "/deploy-config/local.json"); + overrideConfigPath = + string.concat(vm.projectRoot(), "/deployments/deploy-config-resource-minimum-override.json"); + vm.createDir(string.concat(vm.projectRoot(), "/deployments"), true); + } + + function test_read_resourceConfigMinimumBaseFee_default_succeeds() public { + config.read(localConfigPath); + assertEq(config.resourceConfigMinimumBaseFee(), uint32(1 gwei)); + } + + function test_read_resourceConfigMinimumBaseFee_override_succeeds() public { + string memory json = _withResourceConfigMinimumBaseFee(vm.readFile(localConfigPath), 10_000_000); + vm.writeFile(overrideConfigPath, json); + + config.read(overrideConfigPath); + assertEq(config.resourceConfigMinimumBaseFee(), 10_000_000); + + vm.removeFile(overrideConfigPath); + } + + function _withResourceConfigMinimumBaseFee(string memory _json, uint256 _value) + internal + pure + returns (string memory json_) + { + require(_value == 10_000_000, "DeployConfig test: unsupported fixture value"); + + bytes memory jsonBytes = bytes(_json); + uint256 end = jsonBytes.length; + while (end > 0 && (jsonBytes[end - 1] == "\n" || jsonBytes[end - 1] == "\r" || jsonBytes[end - 1] == " ")) { + end--; + } + require(end > 0 && jsonBytes[end - 1] == "}", "DeployConfig test: expected object"); + + bytes memory suffix = bytes(',\n "resourceConfigMinimumBaseFee": 10000000\n}'); + json_ = new string(end - 1 + suffix.length); + + bytes memory result = bytes(json_); + for (uint256 i; i < end - 1; ++i) { + result[i] = jsonBytes[i]; + } + for (uint256 i; i < suffix.length; ++i) { + result[end - 1 + i] = suffix[i]; + } + } +} diff --git a/test/deploy/ResourceConfigMinimumBaseFee.t.sol b/test/deploy/ResourceConfigMinimumBaseFee.t.sol new file mode 100644 index 000000000..05fdc9abf --- /dev/null +++ b/test/deploy/ResourceConfigMinimumBaseFee.t.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { Test } from "lib/forge-std/src/Test.sol"; + +import { SystemDeploy } from "scripts/deploy/SystemDeploy.s.sol"; +import { Types } from "scripts/libraries/Types.sol"; +import { Constants } from "src/libraries/Constants.sol"; +import { Hash, Proposal } from "src/libraries/bridge/Types.sol"; + +import { IL1StandardBridge } from "interfaces/L1/IL1StandardBridge.sol"; +import { IOptimismPortal2 } from "interfaces/L1/IOptimismPortal2.sol"; +import { IResourceMetering } from "interfaces/L1/IResourceMetering.sol"; +import { ISystemConfig } from "interfaces/L1/ISystemConfig.sol"; +import { ISP1Verifier } from "interfaces/L1/proofs/zk/ISP1Verifier.sol"; + +/// @title ResourceConfigMinimumBaseFee_Test +/// @notice Tests deploy-time configuration of the deposit resource minimum base fee. +contract ResourceConfigMinimumBaseFee_Test is Test { + SystemDeploy internal systemDeploy; + + address internal owner = address(this); + address internal guardian = makeAddr("guardian"); + address internal incidentResponder = makeAddr("incidentResponder"); + address internal batcher = makeAddr("batcher"); + address internal unsafeBlockSigner = makeAddr("unsafeBlockSigner"); + address internal proposer = makeAddr("proposer"); + address internal challenger = makeAddr("challenger"); + + uint256 internal l2ChainId = 901; + uint32 internal constant L3_MINIMUM_BASE_FEE = 10_000_000; + uint256 internal constant BASE_TX_GAS_CAP = 16_777_216; + + function setUp() public { + systemDeploy = new SystemDeploy(); + } + + function test_deploy_defaultResourceConfigMinimumBaseFee_succeeds() public { + SystemDeploy.DeployOutput memory output = systemDeploy.deploy(_deployInput(uint32(1 gwei))); + + IResourceMetering.ResourceConfig memory config = output.opChain.systemConfigProxy.resourceConfig(); + assertEq(config.minimumBaseFee, uint32(1 gwei)); + assertEq(config.maximumBaseFee, Constants.DEFAULT_RESOURCE_CONFIG().maximumBaseFee); + + (uint128 prevBaseFee,,) = IOptimismPortal2(payable(address(output.opChain.optimismPortalProxy))).params(); + assertEq(prevBaseFee, config.minimumBaseFee); + } + + function test_deploy_overriddenResourceConfigMinimumBaseFee_succeeds() public { + SystemDeploy.DeployOutput memory output = systemDeploy.deploy(_deployInput(L3_MINIMUM_BASE_FEE)); + + IResourceMetering.ResourceConfig memory config = output.opChain.systemConfigProxy.resourceConfig(); + assertEq(config.minimumBaseFee, L3_MINIMUM_BASE_FEE); + assertEq(config.maximumBaseFee, Constants.DEFAULT_RESOURCE_CONFIG().maximumBaseFee); + + (uint128 prevBaseFee,,) = IOptimismPortal2(payable(address(output.opChain.optimismPortalProxy))).params(); + assertEq(prevBaseFee, L3_MINIMUM_BASE_FEE); + } + + function test_depositETH_lowParentBaseFee_belowTxGasCap_succeeds() public { + SystemDeploy.DeployOutput memory output = systemDeploy.deploy(_deployInput(L3_MINIMUM_BASE_FEE)); + + ISystemConfig systemConfig = output.opChain.systemConfigProxy; + IOptimismPortal2 optimismPortal = IOptimismPortal2(payable(address(output.opChain.optimismPortalProxy))); + IL1StandardBridge l1StandardBridge = IL1StandardBridge(payable(systemConfig.l1StandardBridge())); + + vm.fee(L3_MINIMUM_BASE_FEE); + + uint256 depositorKey = 0xBEEF; + address depositor = vm.addr(depositorKey); + vm.deal(depositor, 10 ether); + + vm.startBroadcast(depositorKey); + uint256 gasBefore = gasleft(); + l1StandardBridge.depositETH{ value: 1 ether }(200_000, hex""); + uint256 gasUsed = gasBefore - gasleft(); + vm.stopBroadcast(); + + assertLt(gasUsed, BASE_TX_GAS_CAP); + + (uint128 prevBaseFee, uint64 prevBoughtGas,) = optimismPortal.params(); + assertEq(prevBaseFee, L3_MINIMUM_BASE_FEE); + assertGt(prevBoughtGas, 0); + } + + function _deployInput(uint32 _resourceConfigMinimumBaseFee) + internal + view + returns (SystemDeploy.DeployInput memory input_) + { + input_.saveArtifacts = false; + input_.superchainInput = SystemDeploy.SuperchainInput({ + guardian: guardian, incidentResponder: incidentResponder, superchainProxyAdminOwner: owner + }); + input_.implementationsInput = SystemDeploy.ImplementationInput({ + withdrawalDelaySeconds: 100, + proofMaturityDelaySeconds: 400, + disputeGameFinalityDelaySeconds: 500, + teeImageHash: bytes32(uint256(1)), + zkRangeHash: bytes32(uint256(2)), + zkAggregationHash: bytes32(uint256(3)), + multiproofConfigHash: bytes32(uint256(4)), + multiproofGameType: 621, + nitroEnclaveVerifier: address(0), + multiproofBlockInterval: 100, + multiproofIntermediateBlockInterval: 10, + multiproofMaxUpgradeId: 12, + sp1Verifier: ISP1Verifier(address(0)), + teeProposer: proposer, + teeChallenger: challenger, + devTeeSigner: address(0), + guardian: guardian, + incidentResponder: incidentResponder, + slowFinalizationDelay: 5 days, + fastFinalizationDelay: 1 days + }); + input_.opChainInput = Types.DeployInput({ + roles: Types.Roles({ + opChainProxyAdminOwner: owner, + systemConfigOwner: owner, + batcher: batcher, + unsafeBlockSigner: unsafeBlockSigner, + incidentResponder: incidentResponder + }), + basefeeScalar: 100, + blobBasefeeScalar: 200, + l2ChainId: l2ChainId, + startingAnchorRoot: Proposal({ root: Hash.wrap(bytes32(uint256(1))), l2SequenceNumber: 0 }), + saltMixer: "resource-config-minimum-base-fee-test", + gasLimit: 60_000_000, + resourceConfigMinimumBaseFee: _resourceConfigMinimumBaseFee + }); + } +} diff --git a/test/deploy/SystemDeploy.t.sol b/test/deploy/SystemDeploy.t.sol index e5a0ebc31..97c483146 100644 --- a/test/deploy/SystemDeploy.t.sol +++ b/test/deploy/SystemDeploy.t.sol @@ -427,7 +427,8 @@ contract SystemDeploy_Test is Test, SystemDeployAssertions { l2ChainId: l2ChainId, startingAnchorRoot: Proposal({ root: Hash.wrap(bytes32(uint256(1))), l2SequenceNumber: 0 }), saltMixer: "system-deploy-test", - gasLimit: 60_000_000 + gasLimit: 60_000_000, + resourceConfigMinimumBaseFee: uint32(1 gwei) }); } @@ -765,7 +766,8 @@ contract ZKBricking_Test is Test { l2ChainId: l2ChainId, startingAnchorRoot: Proposal({ root: Hash.wrap(bytes32(uint256(1))), l2SequenceNumber: 0 }), saltMixer: "zk-bricking-test", - gasLimit: 60_000_000 + gasLimit: 60_000_000, + resourceConfigMinimumBaseFee: uint32(1 gwei) }); } } From cf0adf7e9bc15548730623ada095b85c3b78a9ba Mon Sep 17 00:00:00 2001 From: Derek Guenther Date: Wed, 12 Aug 2026 18:40:07 -0400 Subject: [PATCH 2/3] style(test): format DeployConfig resource minimum base fee tests Co-Authored-By: Claude --- test/deploy/DeployConfig.t.sol | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/deploy/DeployConfig.t.sol b/test/deploy/DeployConfig.t.sol index 5e21f80fa..3160ab633 100644 --- a/test/deploy/DeployConfig.t.sol +++ b/test/deploy/DeployConfig.t.sol @@ -35,7 +35,10 @@ contract DeployConfig_ResourceConfigMinimumBaseFee_Test is Test { vm.removeFile(overrideConfigPath); } - function _withResourceConfigMinimumBaseFee(string memory _json, uint256 _value) + function _withResourceConfigMinimumBaseFee( + string memory _json, + uint256 _value + ) internal pure returns (string memory json_) From 998acb8316f595f9bc1cfc7de03eb9f0d5b667e6 Mon Sep 17 00:00:00 2001 From: Derek Guenther Date: Thu, 13 Aug 2026 15:38:14 -0400 Subject: [PATCH 3/3] test(deploy): cover Fusaka deposit gas regression Co-Authored-By: Claude --- .../deploy/ResourceConfigMinimumBaseFee.t.sol | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/test/deploy/ResourceConfigMinimumBaseFee.t.sol b/test/deploy/ResourceConfigMinimumBaseFee.t.sol index 05fdc9abf..6f2db2f41 100644 --- a/test/deploy/ResourceConfigMinimumBaseFee.t.sol +++ b/test/deploy/ResourceConfigMinimumBaseFee.t.sol @@ -29,6 +29,7 @@ contract ResourceConfigMinimumBaseFee_Test is Test { uint256 internal l2ChainId = 901; uint32 internal constant L3_MINIMUM_BASE_FEE = 10_000_000; + /// @dev The Fusaka EIP-7825 transaction gas cap. Chains may not enable it and future forks may change it. uint256 internal constant BASE_TX_GAS_CAP = 16_777_216; function setUp() public { @@ -66,21 +67,35 @@ contract ResourceConfigMinimumBaseFee_Test is Test { vm.fee(L3_MINIMUM_BASE_FEE); + uint256 gasUsed = _depositETHAndMeasureGas(l1StandardBridge); + assertLt(gasUsed, BASE_TX_GAS_CAP); + + (uint128 prevBaseFee, uint64 prevBoughtGas,) = optimismPortal.params(); + assertEq(prevBaseFee, L3_MINIMUM_BASE_FEE); + assertGt(prevBoughtGas, 0); + } + + function test_depositETH_defaultMinimum_lowParentBaseFee_exceedsFusakaTxGasCap_succeeds() public { + SystemDeploy.DeployOutput memory output = systemDeploy.deploy(_deployInput(uint32(1 gwei))); + IL1StandardBridge l1StandardBridge = + IL1StandardBridge(payable(output.opChain.systemConfigProxy.l1StandardBridge())); + + vm.fee(L3_MINIMUM_BASE_FEE); + + uint256 gasUsed = _depositETHAndMeasureGas(l1StandardBridge); + assertGe(gasUsed, BASE_TX_GAS_CAP); + } + + function _depositETHAndMeasureGas(IL1StandardBridge _l1StandardBridge) internal returns (uint256 gasUsed_) { uint256 depositorKey = 0xBEEF; address depositor = vm.addr(depositorKey); vm.deal(depositor, 10 ether); vm.startBroadcast(depositorKey); uint256 gasBefore = gasleft(); - l1StandardBridge.depositETH{ value: 1 ether }(200_000, hex""); - uint256 gasUsed = gasBefore - gasleft(); + _l1StandardBridge.depositETH{ value: 1 ether }(200_000, hex""); + gasUsed_ = gasBefore - gasleft(); vm.stopBroadcast(); - - assertLt(gasUsed, BASE_TX_GAS_CAP); - - (uint128 prevBaseFee, uint64 prevBoughtGas,) = optimismPortal.params(); - assertEq(prevBaseFee, L3_MINIMUM_BASE_FEE); - assertGt(prevBoughtGas, 0); } function _deployInput(uint32 _resourceConfigMinimumBaseFee)