Introduction
This package contains the L1 and L2 smart contracts for the OP Stack. Detailed specifications for the contracts contained within this package can be found at specs.optimism.io. High-level information about these contracts can be found within this book and within the Optimism Developer Docs. For more information about contributing to OP Stack smart contract development, read on in this book.
Contributing
Contributing Guide
Contributions to the OP Stack are always welcome. Please refer to the CONTRIBUTING.md for general information about how to contribute to the OP Stack monorepo.
When contributing to the contracts-bedrock package there are some additional steps you should follow. These have been
conveniently packaged into a just command which you should run before pushing your changes.
just pre-pr
Style Guide
OP Stack smart contracts should be written according to the style guide found within this book. Maintaining a consistent code style makes code easier to review and maintain, ultimately making the development process safer.
Contract Interfaces
OP Stack smart contracts use contract interfaces in a relatively unique way. Please refer to the [interfaces guide] ifaces to read more about how the OP Stack uses contract interfaces.
Solidity Versioning
OP Stack smart contracts are designed to utilize a single, consistent Solidity version. Please refer to the Solidity upgrades guide to understand the process for updating to newer Solidity versions.
Smart Contract Style Guide
- Standards and Conventions
- Withdrawing From Fee Vaults
This document provides guidance on how we organize and write our smart contracts.
Notes:
- There are many cases where the code is not up to date with this guide, when in doubt, this guide should take precedence.
- For cases where this document does not provide guidance, please refer to existing contracts,
with priority on the
SystemConfigandOptimismPortal.
Standards and Conventions
Style
Comments
Optimism smart contracts follow the triple-slash solidity natspec comment style with additional rules. These are:
- Always use
@noticesince it has the same general effect as@devbut avoids confusion about when to use one over the other. - Include a newline between
@noticeand the first@param. - Include a newline between
@paramand the first@return. - Use a line-length of 100 characters.
We also have the following custom tags:
@custom:proxied: Add to a contract whenever it's meant to live behind a proxy.@custom:upgradeable: Add to a contract whenever it's meant to be inherited by an upgradeable contract.@custom:semver: Add toversionvariable which indicate the contracts semver.@custom:legacy: Add to an event or function when it only exists for legacy support.@custom:network-specific: Add to state variables which vary between OP Chains.
Errors
- Prefer custom Solidity errors for all new errors.
- Name custom errors using
ContractName_ErrorDescription. - Use
revert ContractName_ErrorDescription()to revert. - Avoid
revert(string)and string-typed error messages in new code.
Example:
// ✅ Correct - Custom errors with contract-prefixed names
contract SystemConfig {
error SystemConfig_InvalidFeatureState();
error SystemConfig_UnauthorizedCaller(address caller);
address internal owner;
function setFeature(bool _enabled) external {
if (msg.sender != owner) revert SystemConfig_UnauthorizedCaller(msg.sender);
if (!_enabled) revert SystemConfig_InvalidFeatureState();
// ...
}
}
// ❌ Incorrect - string-based reverts and contract-prefixed strings
function bad(uint256 _amount) external {
require(_amount > 0, "MyContract: amount must be > 0"); // Prefer custom error
revert("MyContract: unsupported"); // Avoid string reverts
}
Function Parameters
- Function parameters should be prefixed with an underscore.
Example:
// ✅ Correct - parameters are prefixed with underscore
function setOwner(address _newOwner) external {
// ...
}
// ❌ Incorrect - parameters without underscore prefix
function setOwner(address newOwner) external {
// ...
}
Function Return Arguments
- Arguments returned by functions should be suffixed with an underscore.
Example:
// ✅ Correct - return variable is suffixed with underscore
function balanceOf(address _account) public view returns (uint256 balance_) {
balance_ = balances[_account];
}
// ❌ Incorrect - return variable without underscore suffix
function balanceOf(address _account) public view returns (uint256 balance) {
balance = balances[_account];
}
Event Parameters
- Event parameters should be named using camelCase.
- Event parameters should NOT be prefixed with an underscore.
Example:
// ✅ Correct - event params are not prefixed with underscore
event OwnerChanged(address previousOwner, address newOwner);
// ❌ Incorrect - event params prefixed with underscore
event OwnerChanged(address _previousOwner, address _newOwner);
// ❌ Incorrect - event params are not camelCase or are unnamed
event OwnerChanged(address, address NEW_OWNER);
Immutable variables
Immutable variables:
- should be in
SCREAMING_SNAKE_CASE - should be
internal - should have a hand written getter function
This approach clearly indicates to the developer that the value is immutable, without exposing the non-standard casing to the interface. It also ensures that we don’t need to break the ABIs if we switch between values being in storage and immutable.
Example:
contract ExampleWithImmutable {
// ❌ Incorrect - immutable is not SCREAMING_SNAKE_CASE
address internal immutable ownerAddress;
// ❌ Incorrect - immutable is public
address public immutable ownerAddress;
// ✅ Correct - immutable is internal and SCREAMING_SNAKE_CASE
address internal immutable OWNER_ADDRESS;
constructor(address _owner) {
OWNER_ADDRESS = _owner;
}
// ✅ Handwritten getter
function ownerAddress() public view returns (address) {
return OWNER_ADDRESS;
}
}
Spacers
We use spacer variables to account for old storage slots that are no longer being used.
The name of a spacer variable MUST be in the format spacer_<slot>_<offset>_<length> where
<slot> is the original storage slot number, <offset> is the original offset position
within the storage slot, and <length> is the original size of the variable.
Spacers MUST be private.
Example:
contract ExampleStorageV2 {
// ✅ Correct - spacer preserves old storage layout
bytes32 private spacer_5_0_32;
uint256 public value;
}
// ❌ Incorrect - wrong visibility and/or naming
contract BadStorageLayout {
bytes32 internal spacer5;
}
Proxy by Default
All contracts should be assumed to live behind proxies (except in certain special circumstances).
This means that new contracts MUST be built under the assumption of upgradeability.
We use a minimal Proxy contract designed to be owned by a
corresponding ProxyAdmin which follow the interfaces
of OpenZeppelin's Proxy and ProxyAdmin contracts, respectively.
Unless explicitly discussed otherwise, you MUST include the following basic upgradeability pattern for each new implementation contract:
- Extend OpenZeppelin's
Initializablebase contract. - Include a function
initializewith the modifierinitializer(). - In the
constructor:- Call
_disableInitializers()to ensure the implementation contract cannot be initialized. - Set any immutables. However, we generally prefer to not use immutables to ensure the same implementation contracts can be used for all chains, and to allow chain operators to dynamically configure parameters
- Call
Because reinitializer(uint64 version) is not used, the process for upgrading the implementation is to atomically:
- Upgrade the implementation to the
StorageSettercontract. - Use that to set the initialized slot (typically slot 0) to zero.
- Upgrade the implementation to the desired new implementation and
initializeit.
Versioning
All (non-library and non-abstract) contracts MUST inherit the ISemver interface which
exposes a version() function that returns a semver-compliant version string.
Contracts must have a version of 1.0.0 or greater to be production ready.
Additionally, contracts MUST use the following versioning scheme when incrementing their version:
patchreleases are to be used only for changes that do NOT modify contract bytecode (such as updating comments).minorreleases are to be used for changes that modify bytecode OR changes that expand the contract ABI provided that these changes do NOT break the existing interface.majorreleases are to be used for changes that break the existing contract interface OR changes that modify the security model of a contract.
The remainder of the contract versioning and release process can be found in `VERSIONING.md.
Exceptions
We have made an exception to the Semver rule for the WETH contract to avoid
making changes to a well-known, simple, and recognizable contract.
Additionally, bumping the patch version does change the bytecode, so another exception is carved out for this. In other words, changing comments increments the patch version, which changes bytecode. This bytecode change implies a minor version increment is needed, but because it's just a version change, only a patch increment should be used.
Dependencies
Where basic functionality is already supported by an existing contract in the OpenZeppelin library, we should default to using the Upgradeable version of that contract.
Interface Inheritance
In order to reduce build times, all external dependencies (ie. a contract that is being interacted with)
should be imported as interfaces. In order to facilitate this, implementation contracts must have an
associated interface in the interfaces/ directory of the contracts package. Checks in CI
will ensure that the interface exists and is correct. These interfaces should include a
"pseudo-constructor" function (function __constructor__()) which ensures that the constructor's
encoding is exposed in the ABI.
Contracts must not inherit from their own interfaces (e.g., contract SomeContract is ISomeContract).
Interfaces may or may not inherit from other interfaces to compose functionality.
Rationale:
- Alignment Issues: If a contracts inherits from a base contracts (like
Ownable), it cannot inherit from the interface as well, as this prevents 1:1 alignment between the implementation and interface, since the interface cannot include the base contract functions (ie.owner()) without causing compiler errors. - Constructor Complications: Interface inheritance can cause issues with pseudo-constructors.
Example:
// ✅ Correct - contract inherits from base contracts, interface composes other interfaces
contract SomeContract is SomeBaseContract, ... {
// Implementation
}
interface ISomeContract is ISomeBaseContract {
// Interface definition
}
// ❌ Incorrect - contract inheriting from its own interface
contract SomeContract is ISomeContract, ... {
// This creates alignment and compilation issues
}
Source Code
The following guidelines should be followed for all contracts in the src/ directory:
- All state changing functions should emit a corresponding event. This ensures that all actions are transparent, can be easily monitored, and can be reconstructed from the event logs.
Tests
Tests are written using Foundry.
All test contracts and functions should be organized and named according to the following guidelines.
These guidelines are enforced by a validation script which can be run with:
just lint-forge-tests-check-no-build
The script validates both function naming conventions and contract structure requirements.
Expect Revert with Low Level Calls
There is a non-intuitive behavior in foundry tests, which is documented here.
When testing for a revert on a low-level call, please use the revertsAsExpected pattern suggested there.
Note: This is a work in progress, not all test files are compliant with these guidelines.
Organizing Principles
- Solidity
contracts are used to organize the test suite similar to how mocha uses describe. - Every function should have a separate contract for testing. This helps to make it very obvious where there are not yet tests and provides clear organization by function.
Test function naming convention
Test function names are split by underscores, into 3 or 4 parts. An example function name is test_onlyOwner_callerIsNotOwner_reverts().
The parts are: [method]_[FunctionName]_[reason]_[status], where:
[method]is eithertest,testFuzz, ortestDiff[FunctionName]is the name of the function or higher level behavior being tested.[reason]is an optional description for the behavior being tested.[status]must be one of:succeeds: used for most happy path casesreverts: used for most sad path casesworks: used for tests which include a mix of happy and sad assertions (these should be broken up if possible)fails: used for tests which 'fail' in some way other than revertingbenchmark: used for tests intended to establish gas costs
Detailed Naming Rules
Test function names must follow these strict formatting rules:
- camelCase: Each underscore-separated part must start with a lowercase letter
- Valid:
test_something_succeeds - Invalid:
test_Something_succeeds
- Valid:
- No double underscores: Empty parts between underscores are not allowed
- Valid:
test_something_succeeds - Invalid:
test__something_succeeds
- Valid:
- Part count: Must have exactly 3 or 4 parts separated by underscores
- Failure tests: Tests ending with
revertsorfailsmust have 4 parts to include the failure reason- Valid:
test_transfer_insufficientBalance_reverts - Invalid:
test_transfer_reverts
- Valid:
- Benchmark variants:
- Basic:
test_something_benchmark - Numbered:
test_something_benchmark_123
- Basic:
Contract Naming Conventions
Test contracts should be organized with one contract per function being tested, following these naming patterns:
<ContractName>_TestInitfor contracts that perform initialization/setup to be reused in other test contracts<ContractName>_<FunctionName>_Testfor contracts containing tests for a specific function<ContractName>_Harnessfor basic harness contracts that extend functionality for testing<ContractName>_<Descriptor>_Harnessfor specialized harness contracts (e.g.,OPContractsManager_Upgrade_Harness)<ContractName>_Uncategorized_Testfor miscellaneous tests that don't fit specific function categories
Legacy Notice: The older _TestFail suffix is deprecated and should be updated to _Test with appropriate failure test naming.
Test File Organization
Test files must follow specific organizational requirements:
- File location: Test files must be placed in the
test/directory with.t.solextension - Source correspondence: Each test file should have a corresponding source file in the
src/directory- Test:
test/L1/OptimismPortal.t.sol - Source:
src/L1/OptimismPortal.sol
- Test:
- Name matching: The base contract name (before first underscore) must match the filename
- Function validation: Function names referenced in test contract names must exist in the source contract's public interface
Test Naming Exceptions
Certain types of tests are excluded from standard naming conventions:
- Invariant tests (
test/invariants/): Use specialized invariant testing patterns - Integration tests (
test/integration/): May test multiple contracts together - Script tests (
test/scripts/): Test deployment and utility scripts - Library tests (
test/libraries/): May have different artifact structures - Formal verification (
test/kontrol/): Use specialized tooling conventions - Vendor tests (
test/vendor/): Test external code with different patterns
Withdrawing From Fee Vaults
See the file scripts/FeeVaultWithdrawal.s.sol to withdraw from the L2 fee vaults. It includes
instructions on how to run it. foundry is required.
Interfaces
This document outlines the guidelines and best practices for using and creating interfaces in the
contracts-bedrock package.
Importance of Interfaces
Interfaces are valuable for developers because:
- They allow interaction with OP Stack contracts without importing the source code.
- They provide compatibility across different compiler versions.
- They can reduce contract compilation time.
Example of Interface Usage
Instead of importing the full contract:
import "./ComplexContract.sol";
contract MyContract {
ComplexContract public complexContract;
constructor(address _complexContractAddress) {
complexContract = ComplexContract(_complexContractAddress);
}
function doSomething() external {
complexContract.someFunction();
}
}
You can use an interface:
import "./interfaces/IComplexContract.sol";`
contract MyContract {
IComplexContract public complexContract;
constructor(address _complexContractAddress) {
complexContract = IComplexContract(_complexContractAddress);
}
function doSomething() external {
complexContract.someFunction();
}
}
This approach allows for interaction without being tied to the specific implementation or compiler
version of ComplexContract.
Current Interface Policy
No Interface Imports in Source Contracts
Contrary to common practice, source contracts for which an interface is defined SHOULD NOT use the interface contract. This means:
contract Whatever is IWhateveris NOT allowed.- Source contracts should not use types or other definitions from their interfaces.
- Contracts that build on base contracts (e.g.,
contract OtherWhatever is Whatever) should not importIWhateverorIOtherWhatever.
Correct Implementation Example
Instead of:
import "./IWhatever.sol";
contract Whatever is IWhatever {
// Implementation
}
Do this:
contract Whatever {
// Direct implementation without importing interface
}
Reasons for This Policy
-
Automation Potential: We aim to auto-generate interfaces in the future. Importing interfaces into source contracts would prevent this automation by creating a circular dependency.
-
ABI Compatibility: Achieving 1:1 compatibility between interface and source contract ABI becomes problematic when the source contract imports other contracts along with the interface. This is due to Solidity's handling of function redefinitions. See Example of ABI Compatibility Issue below for more context.
Example of ABI Compatibility Issue
contract SomeBaseContract {
event SomeEvent();
}
interface IWhatever {
event SomeEvent();
function someOtherFunction() external;
}
contract Whatever is IWhatever, SomeBaseContract {
function someOtherFunction() external {}
}
In this case, Solidity will return the following compilation error:
DeclarationError: Event with same name and parameter types defined twice.
Importing External Interfaces
Contracts CAN import interfaces for OTHER contracts. This practice helps mitigate compilation time issues in older Solidity versions. As Solidity improves, we plan to phase out this exception.
Example of Allowed Interface Usage
import "./IOtherContract.sol";
contract MyContract {
IOtherContract public otherContract;
constructor(address _otherContractAddress) {
otherContract = IOtherContract(_otherContractAddress);
}
// Rest of the contract
}
Creating Interfaces
You have several options for creating interfaces:
-
Use
cast interface:cast interface ./path/to/contract/artifact.json -
Use
forge inspect:forge inspect <ContractName> abi --pretty -
Create the interface manually:
interface IMyContract { function someFunction() external; function anotherFunction(uint256 _param) external returns (bool); // ... other functions and events }
Regardless of the method chosen, ensure that your ABIs are a 1:1 match with their source contracts.
NOTE: Using cast interface or forge inspect can fail to preserve certain types like enum
values. You may need to manually fix these issues or CI will complain.
Verifying Interface Accuracy
To check if all interfaces match their source contracts:
- Run
just interface-checkorjust interface-check-no-build
These commands will compare the ABIs of your interfaces with their corresponding source contracts and report any discrepancies.
Future Goals
Our long-term objectives for interfaces include:
- Automating interface generation
- Using interfaces only for external users, not internally
- Eliminating the need for interface imports in source contracts
Until we achieve these goals, we maintain the current policy to balance development efficiency and compilation time improvements.
OP Contracts Manager (OPCM)
The OPCM is an important smart contract that is used to orchestrate OP Chain deployments and upgrades. It is responsible for the following:
- Keeping track of the canonical implementation contracts for each contracts release.
- Deploying new L1 contracts for each OP Chain.
- Upgrading from one contract release to another.
- Maintaining the fault proof system by adding game types or updating prestates.
All contract upgrades that touch live chains must be performed via the OPCM. This guide will walk you through the OPCM's architecture, and how to hook your contracts into it.
Architecture
The OPCM consists of multiple contracts:
OPContractsManager, which serves as the entry point.OPContractsManagerGameTypeAdder, which is used to add new game types and update prestates.OPContractsManagerDeployer, which is used to deploy new OP Chains.OPContractsManagerUpgrader, which is used to upgrade existing OP Chains.OPContractsManagerContractsContainer, which is a repository for contract implementations and blueprints.
They fit together like the diagram below:
stateDiagram-v2
state OpContractsManager {
direction LR
deploy() --> OpContractsManagerDeployer: staticcall
upgrade() --> OpContractsManagerUpgrader: delegatecall
addGameType() --> OpContractsManagerGameTypeAdder: delegatecall
updatePrestate() --> OpContractsManagerGameTypeAdder: delegatecall
}
state Logic {
OpContractsManagerDeployer --> OpContractsManagerContractsContainer: getImplementations()/getBlueprints()
OpContractsManagerUpgrader --> OpContractsManagerContractsContainer: getImplementations()/getBlueprints()
OpContractsManagerGameTypeAdder --> OpContractsManagerContractsContainer: getImplementations()/getBlueprints()
}
state Implementations {
OpContractsManagerContractsContainer
}
One OPCM is deployed per smart contract release per chain. Each OPCM supports deploying a new chain at its corresponding smart contract release, and upgrading existing chains from one version prior to its corresponding smart contract release. Chains that are multiple versions behind must be upgraded in multiple stages across multiple OPCMs.
The OPCM supports upgrading Superchain-wide contracts like ProtocolVersions and the SuperchainConfig.
Usage
Typically, users do not call into the OPCM directly. Instead, they use OP Deployer to either directly
call deploy when spinning up a new chain or generate calldate for use with upgrade.
If you want to call the OPCM directly, check out the implementation to see exactly what the inputs and outputs are to each method. This changes between releases, and will not be covered directly here.
Updating the OPCM
Whenever you make updates to in-protocol contracts, you'll need to make corresponding changes inside the OPCM. While the details of each change will vary, we've included some general guidelines below.
Updating Logic Contracts
As their name implies, the logic contracts contain the actual logic used to deploy or upgrade contracts. When modifying these contracts keep the following tips in mind:
- The
deploymethod can typically be modified in-place since the deployment process doesn't change much from release to release. For example, most changes to thedeploymethod will involve either adding a new contract or modifying the constructor/initializer for existing contracts. You can use the existing implementation as a guide. - The
upgrademethod changes much more frequently. That said, you can still use the existing implementation as a guide. Just make sure to delete any old upgrade code that is no longer needed. TheOPContractsManagerUpgraderlogic contract also contains helpers for things like deploying new dispute games and upgrading proxies to new implementations. See theupgradeTomethod for an example. - The
upgrademethod will always set the RC on the OPCM to false when called by the upgrade controller. It will only sometimes (depending on your specific upgrade) upgrade Superchain contracts.
Fork Tests
The OPCM is tested using fork tests. These tests fork mainnet and "run" the upgrade against OP Mainnet. This allows us to validate that the upgrades work as expected in CI prior to deploying them to betanets or production.
To run fork tests, run just test-upgrade. You will need to set ETH_RPC_URL to an archival mainnet node.
When multiple upgrades are in flight at the same time, the fork tests stack upgrades on top of one another. Since the
tip of develop must contain the implementation for the latest upgrade only, fork tests that run upgrades prior to
the latest one must use deployed instances of the OPCM. For example, as of this writing upgrades 13, 14, and 15 are
all in flight. Therefore, the fork tests will use deployed versions of the OPCM for upgrades 13 and 14 and whatever
is on develop for upgrade 15. See OPContractsManager.t.sol for the implementation of the fork tests.
Modifying Contracts for Upgrade
When making changes to a contract there are few situations to bear in mind.
The contract's initializer() is updated
Typically a contract's initializer() will be modified when a new storage variable is added which
needs to be set. This may either be done by adding a new argument to the initializer(), by
reading a value from the environment, or by reading a value from another contract.
Whatever the case, a new upgrade() method should also be added which uses the same logic to set
the new storage variable.
In addition, the contract should inherit from ReinitializableBase and the initializer() and upgrade()
methods should have the reinitializer(reinitVersion()) modifier.
The OPCM must then use ProxyAdmin.upgradeAndCall() to call the upgrade() method. Additionally, if the
input value is not read from the chain, then it will need to be passed in as input. This will
require a new field to be added to the OpChainConfig struct.
New derivation path events are being added
If a contract emits events which have an impact on the derivation path of a chain (this most often
occurs when a new UpdateType is added to the SystemConfig.ConfigUpdate() event), the best
practice is to:
- Not emit the new event in the
initialize()method (and not add anupgrade()method) - Have the
op-nodedefault to some value for that config. - Add a setter function with appropriate auth checks for the new config value.
This pattern has a few benefits. It enables L1 contracts to be upgraded in advance of an L2 hardfork
which reads these events. Another benefit is that it avoids the need to provide the new config value
as an input to the OpChainConfig, thus helping to keep the interface of the OPCM stable.
No new storage variables or derivation path events are added
In this case no changes are required to initialize(), no new upgrade() method is needed, and the
OPCM can simply use the ProxyAdmin.upgrade() method to upgrade the contract.
Solidity Versioning Policy
This document outlines the process for proposing and implementing Solidity version updates in the OP Stack codebase.
Unified Solidity Version
The OP Stack codebase maintains a single, unified Solidity version across all contracts and components. This ensures consistency, simplifies maintenance, and reduces the risk of version-related issues.
Important: New Solidity versions must not be introduced to any part of the codebase without going through the formal version update proposal process outlined in this document.
Update Process
- Minimum Delay Period: A new Solidity version must be at least 6 months old before it can be considered for adoption.
- Proposal Submission: Before any Solidity version upgrades are made, a detailed proposal must
be submitted as a pull request to the
ethereum-optimism/design-docsrepository in thesolidity/subfolder, following the standardized format outlined below. This applies to the entire codebase; individual components or contracts cannot be upgraded separately. - Review and Approval: A dedicated review panel will assess the proposal based on the
following criteria:
- Is the Solidity version at least 6 months old?
- Does the proposed upgrade provide clear value to the codebase?
- Do any new features or bug fixes pose an unnecessary risk to the codebase?
- Implementation: If the proposal receives unanimous approval from the review panel, the Solidity version upgrade will be implemented across the entire OP Stack codebase.
Proposal Submission Guidelines
To submit a Solidity version upgrade proposal, create a new pull request to the
ethereum-optimism/design-docs repository, adding a new file in the solidity/
subfolder. Please use the dedicated Solidity update proposal format. Ensure that all sections
are filled out comprehensively. Incomplete proposals may be delayed or rejected.
Review Process
The review panel will evaluate each proposal based on the criteria mentioned in the "Review and Approval" section above. They may request additional information or clarifications if needed.
Implementation
If approved, the Solidity version upgrade will be implemented across the entire OP Stack codebase. This process will be managed by the development team to ensure consistency and minimize potential issues. The upgrade will apply to all contracts and components simultaneously.
Smart Contract Versioning and Release Process
The Smart Contract Versioning and Release Process closely follows a true semver for both individual contracts and monorepo releases. However, there are some changes to accommodate the unique nature of smart contract development and governance cycles.
There are five parts to the versioning and release process:
- Semver Rules: Follows the rules defined in the style guide for when to bump major, minor, and patch versions in individual contracts.
- Individual Contract Versioning: The versioning scheme for individual contracts and includes beta, release candidate, and feature tags.
- Monorepo Contracts Release Versioning: The versioning scheme for monorepo smart contract releases.
- Release Process: The process for deploying contracts, creating a governance proposal, and the required associated releases.
- Additional Release Candidates: How to handle additional release candidates after an initial
op-contracts/vX.Y.Z-rc.1release.
- Additional Release Candidates: How to handle additional release candidates after an initial
[!NOTE] The rules described in this document must be enforced manually. Ideally, a check can be added to CI to enforce the conventions defined here, but this is not currently implemented.
Semver Rules
Version increments follow the style guide rules for when to bump major, minor, and patch versions in individual contracts:
patchreleases are to be used only for changes that do NOT modify contract bytecode (such as updating comments).minorreleases are to be used for changes that modify bytecode OR changes that expand the contract ABI provided that these changes do NOT break the existing interface.majorreleases are to be used for changes that break the existing contract interface OR changes that modify the security model of a contract.Bumping the patch version does change the bytecode, so another exception is carved out for this. In other words, changing comments increments the patch version, which changes bytecode. This bytecode change implies a minor version increment is needed, but because it's just a version change, only a patch increment should be used.
Individual Contract Versioning
Individual contract versioning allows us to uniquely identify which version of a contract from the develop branch corresponds to each deployed contract instance.
Versioning for individual contracts works as follows:
- A contract on develop always has a version of X.Y.Z, regardless of whether is has been governance approved and meets our security bar. This DOES NOT indicate these contracts are always safe for production use. More on this below.
- For contracts with feature-specific changes, a
+feature-nameidentifier must be appended to the version number. See the Smart Contract Feature Development design document to learn more. - When making changes to a contract, always bump to the lowest possible version based on the specific change you are making. We do not want to e.g. optimistically bump to a major version, because protocol development sequencing may change unexpectedly. Use these examples to know how to bump the version:
- Example 1: A contract is currently on
1.2.3ondevelopand you are working on a new feature on yourfeaturebranch offdevelop.- We don't yet know when the next release of this contract will be. However, you are simply fixing typos in comments so you bump the version to
1.2.4. - The next commit to the
featurebranch clarifies some comments. We only consider the aggregatedfeaturechanges with regards todevelopwhen determining the version, so we stay at1.2.4. - The next commit to the
featurebranch introduces a breaking change, which bumps the version from1.2.4to2.0.0.
- We don't yet know when the next release of this contract will be. However, you are simply fixing typos in comments so you bump the version to
- Example 2: A contract is currently on
2.4.7.- We know the next release of this contract will be a breaking change. Regardless, as you start development by fixing typos in comments, bump the version to
2.4.8. This is because we may end up putting out a release before the breaking change is added. - Once you start working on the breaking change, bump the version to
3.0.0.
- We know the next release of this contract will be a breaking change. Regardless, as you start development by fixing typos in comments, bump the version to
- Example 1: A contract is currently on
- New contracts start at
1.0.0.
Versioning is enforced by CI checks:
- Any contract that differs from its version in the
developbranch must be bumped to a new semver value, or the build will fail. - Any branch with at least one modified contract must have its
semver-lock.jsonfile updated, or the build will fail. You can use thesemver-lockorpre-commitjust commands to do so.
Note: Previously, the versioning scheme included -beta.n and -rc.n qualifiers. These are no longer used to reduce the amount of work required to execute this versioning system.
Deprecating Individual Contract Versioning
Individual contract versioning could be deprecated when the following conditions are met:
- Every OPCM instance is registered in the superchain registry
- All contracts are implemented as either proxies or concrete singletons, allowing verification of governance approval through the
OPCM.Implementationsstruct - We have validated with engineering teams (such as the fault proofs team) and ecosystem partners (such as L2Beat) that removing
version()functions would not negatively impact their workflows
Monorepo Contracts Release Versioning
Versioning for monorepo releases works as follows:
- Monorepo releases continue to follow the
op-contracts/vX.Y.Znaming convention. - The version used for the next release is determined by the highest version bump of any individual contract in the release.
- Example 1: The monorepo is at
op-contracts/v1.5.0. Clarifying comments are made in contracts, so all contracts only bump the patch version. The next monorepo release will beop-contracts/v1.5.1. - Example 2: The monorepo is at
op-contracts/v1.5.1. Various tech debt and code is cleaned up in contracts, but no features are added, so at most, contracts bumped the minor version. The next monorepo release will beop-contracts/v1.6.0. - Example 3: The monorepo is at
op-contracts/v1.5.1. LegacyALL_CAPS()getter methods are removed from a contract, causing that contract to bump the major version. The next monorepo release will beop-contracts/v2.0.0.
- Example 1: The monorepo is at
- Feature specific monorepo releases (such as a release of the custom gas token feature) are supported, and should follow the guidelines in the Smart Contract Feature Development design doc. Bump the overall monorepo semver as required by the above rules. For example, if the last release before the custom gas token feature was
op-contracts/v1.5.1, because the custom gas token introduces breaking changes, its release will beop-contracts/v2.0.0.- A subsequent release of the custom gas token feature that fixes bugs and introduces an additional breaking change would be
op-contracts/v3.0.0. - This means
+feature-namenaming is not used for monorepo releases, only for individual contracts as described below.
- A subsequent release of the custom gas token feature that fixes bugs and introduces an additional breaking change would be
- A monorepo contracts release must map to an exact set of contract semvers, and this mapping must be defined in the contract release notes which are the source of truth. See
op-contracts/v1.4.0-rc.4for an example of what release notes should look like.
Optimism Contracts Manager (OPCM) Versioning
The OPCM is the contract that manages the deployment of all contracts on L1.
The OPCM is the source of truth for the contracts that belong in a release, available as on-chain addresses by querying the getImplementations function.
Tagging and Release Process
Creating a tagged release
First select a tag string based on the guidance in Monorepo Contracts Release Versioning
- Checkout the commit
- Run
git tag <tag-string> - Run
git push origin <tag-string>Repo rules require this is done by someone who is a release-manager. Once pushed a tag cannot be deleted, so please be sure it is correct. - Create release notes in Github:
- Go to the Releases page, enter or select
<tag-string>from the dropdown.
- Go to the Releases page, enter or select
- Populate the release notes. If the tag is a release candidate, check the
Set as a pre-releaseoption, and uncheck theSet as the latest releaseoption. - Deploy the OPCM using the following op-deployer just recipes (which call the
op-deployer bootstrap implementationscommand), this will write the addresses of the deployed contracts tostdout(or to disk if you provide an--outfileargument).
Deploy and verify contracts on both Sepolia and Mainnet.cd op-deployer just build // compiles contracts, builds go binary just deploy-opcm // deploys the implementations contracts bundle just verify-opcm // verifies contracts on block-explorer - In the superchain-registry edit the following files to add a new
[<tag-string>]entry, with the addresses from the previous step: - Once the changes are merged into the superchain-registry, you can follow the instructions
for creating a new release of
op-deployer.
Implications for audits
The process above should be followed to create an -rc.1 release prior to audit. This will be the target commit for
the audit. If any fixes are required by the audit results an Additional Release Candidate will be required.
Additional Release Candidates
Sometimes fixes or additional changes need to be added to a release candidate version. In that case we want to ensure fixes are made on both the release and the trunk branch, without stopping development efforts on the trunk branch.
The process is as follows:
- Make the fixes on
develop. Increment the contracts semver as normal. - Create a new release branch, named
proposal/op-contracts/vX.Y.Zoff of the rc tag (all subsequent-rctags will be made from this branch). - Cherry pick the fixes from
developinto the release branch, and increment the semver as normal. If this increment results in any of the modified contracts' semver being equal to or greater than it is ondevelop, then the semver should immediately be increased ondevelopto be greater than on the release branch. This avoids a situation where a given contract has two different implementations with the same version. - After merging the changes into the new release branch, tag the resulting commit on the proposal branch as
op-contracts/vX.Y.Z-rc.n. Create a new release for this tag per the instructions above.
Finalizing a release
Once a release has passed governance, a new tag should be created without the -rc.n suffix. To do this follow the
instructions in "Creating a tagged release" once again. It should not be necessary to redeploy the contracts with op-deployer,
but a new entry will be required in the superchain-registry's toml files regardless.
When creating release notes, uncheck the Set as a pre-release option, and uncheck the
Set as the latest release option (latest releases are reserved for non-contract packages).