Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions protocol/l2-contract-upgrades.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
## Purpose

To enable deterministic, well tested, hard fork driven upgrades of L2 contracts, with both the implementation and upgrade path written in Solidity.

The customers for this work include:

- protocol devs seeking to modify L2 contracts
- chain operators looking for improvements
- anyone who has ever worried about unclear and inconsistent versioning of L2 contracts across OP chains

## Summary

The proposed design maintains the current method of injecting Network Upgrade Transactions (NUTs) at a specific fork block height, while improving on the development and testing process in order to better enable safe, well-tested, multi-client upgrades of L2 contracts.

This is achieved by putting the transaction data into a JSON file, and then executing it at the specified fork block.

The design aims to create a solution which parallels the OPCM, but for L2.

## Problem Statement + Context

The status quo for L2 contract development suffers from the following problems:

1. **Separation of development and testing flows:** Contracts are implemented in Solidity, but upgraded with Go, making the upgrade path difficult to test.
2. **Separation between upgrade and deployment flows:** Deployment of L2 contracts is done during L2 Genesis generation (a solidity script). Conversely upgrading happens in NUT files (go scripts).
3. **Lack of feature flags and network specific configuration:** We don't have a mechanism to modify upgrades based on config or feature flags.
4. **Release and Versioning issues:** We have an L1 contracts release process, but we don’t have a parallel process for L2 contracts.
5. **Lack of verifiability:** We don’t have a way to ensure that the bytecode in the NUT file is actually generated by the compiled output of the source code on a given commit.
6. **Fragmentation of L2 contract versions:** Different OP Chains are running different versions of the L2 contracts, and we don't know who is running what.
7. **Stagnant L2 contracts:** We're afraid to make changes to L2 contracts, because we don't have a good way to roll them out.

## Requirements

The design described here is intended to fulfill the following requirements:

- Upgrades must be 100% deterministic with a clear, immutable record of the exact transactions and bytecode executed.
- Execution must occur at an exact fork timestamp for consensus-coupled contracts like L1Block.
- Clear provenance of upgrade bytecode, ie. it is easy to verify that the upgrade bytecode comes from the solidity source code of a given commit.
- Upgrade paths should be testable end-to-end, ideally authored in Solidity and exercised in local and CI environments
- Support for feature flags
- Provide a mechanism for different implementations ([specs](https://specs.optimism.io/protocol/fjord/derivation.html#network-upgrade-automation-transactions), Go, Rust, etc.) to keep upgrade transactions in sync.
- Clarify how L2 contracts are versioned and released.

### Non-requirements

The design does not attempt to fulfil these non-requirements:

- Improving or simplifying how L2 contract upgrades can be initiated via actions taken on L1. For example this project will not add a new method to an L1 contract which triggers a `TransactionDeposited` with specific data to cause an upgrade to occur to an L2 contract (ie. like [this](https://www.notion.so/A-mechanism-for-upgrading-L2-contracts-via-L1-transactions-2249adb08eb64cd193cc7cd30c146603?pvs=21) earlier proposal).
- The ability to inject network specific configuration data into an upgrade transaction. This has not been required in previous hardforks and the need is not foreseen in the near future.

It is worth noting that the requirements above are primarily provided for the L1 contracts by the OPCM and tooling associated with it. A nice-to-have is to reuse existing OPCM related code, patterns and tooling so that the experience of developing and upgrading L2 contracts aligns as closely as possible with that of L1 contracts.

<aside>
📢

The source of truth for these requirements in maintained in the [PR FAQ](https://www.notion.so/L2ContractManagement-PR-FAQ-281f153ee16280c09c74c19963c06b05?pvs=21) here.

</aside>

### Milestones

The project of overhauling how L2 contracts are managed is a large one. In order to control scope, the deliverable for this work at hand will be to upgrade a subset of L2 contracts via hard fork to bring them in line with the source code in the monorepo.
The exact set of contracts is yet to be determined, but we will skip upgrading any edge-case contracts which require extra effort to upgrade.

**Future Milestone**
This design also does not attempt to address Standard Genesis generation, which is reserved for future milestones.

## Proposed Solution

Network upgrade transactions will continue to be executed at a [specified fork block height](https://github.com/ethereum-optimism/optimism/blob/2fdd97053ba8fb2bc27dcb38dcdc42ade7c8c78d/op-node/rollup/derive/attributes.go#L148-L154), except that rather than executing a set of transactions defined in a stand alone go file, the transactions will be read from a JSON file.

The NUT file will be stored in the monorepo, and tracked by git. It will be updated by the `just snapshots` command.

### What upgrade transactions will do

A given set of upgrade transactions will typically perform the following actions:

1. deploy new `ConditionalDeployer` contract (This step will only be needed the first time this scheme is used)
2. deploy new implementations via the `ConditionalDeployer`
2. deploy a new `L2ContractsManager`
3. update the `ProxyAdmin` (This step will only be needed the first time this scheme is used)
4. execute a call to `ProxyAdmin.upgradePredeploys()`
1. This function will `DELEGATECALL` the new `L2ContractsManager`'s `upgrade()` function.
2. For all predeploys being upgrade, `L2ContractsManager.upgrade()` will make a call to that predeploy's `upgradeTo()` function

### New ConditionalDeployer

A new `ConditionalDeployer` contract is needed to ensure that step 2 "deploy new implementations" can preserve the following properties of the L1 OPCM system:
Copy link

@0xiamflux 0xiamflux Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the intention to deploy the conditional deployer on every hard fork? I think it only needs to be done once, and if so, perhaps it's worth specifying it just like we do with ProxyAdmin. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good call out, addressed in this commit


1. If a contract's bytecode is unchanged, then the implementation address will be unchanged. This
is achieved on L1 in the DeployImplemenations script's [use](https://github.com/ethereum-optimism/optimism/blob/60f0c8d0beb2ea22f0ebc11416a22978b182dbfa/packages/contracts-bedrock/scripts/deploy/DeployImplementations.s.sol#L260) of [`createDeterministic`](https://github.com/ethereum-optimism/optimism/blob/60f0c8d0beb2ea22f0ebc11416a22978b182dbfa/packages/contracts-bedrock/scripts/libraries/DeployUtils.sol#L145).
2. A developer does not need to think about whether or not to include a given Predeploy in the
upgrade. The Predeploy will always be upgraded to the latest implementation address. If
the contract is unchanged, then its bytecode and implementation address will be unchanged, but
regardless the upgrade call will always be included in the `L2ContractsManager` so that it need not
be edited between upgrades.

The `ConditionalDeployer` contract will be very minimal contract which will receive the initcode and then:
1. determine whether a create2 collision will occur
2. if not: forward the initcode to the [determinstic-deployer](https://github.com/Arachnid/deterministic-deployment-proxy/blob/master/source/deterministic-deployment-proxy.yul#L13) contract.
3. if yes: return

The `ConditionalDeployer` should not revert if called with properly formed inputs.


### New L2 ProxyAdmin

The `ProxyAdmin` predeploy (at `0x42...18`) will be upgraded with a new implementation which:

1. maintains the current interface for backwards compatibility
2. removes code which is necessary to support the Chugsplash and Resolved Delegate proxies (Optional, can be cut from scope)
3. gets a new `upgradePredeploys()` function (as described above)
1. This function is only callable by the Depositor account (`0xdeaddead…0001`)

### The L2ContractsManager contract

The initial version of the `L2ContractsManager` developed for this work will be minimal, and its `upgrade()` function must not accept any arguments in order to maintain the determinism of the upgrade bundle.

In pseudocode it will do the following:

```python
# Contains network specific config values
class L2Config:
l1CrossDomainMessengerConfig: L2CrossDomainMessengerConfig
l2StandardBridgeConfig: L2StandardBridgeConfig
l2ERC721BridgeConfig: L2ERC721BridgeConfig
# etc.

def gather_config() -> (L2Config):
# Calls to existing predeploys to ready any network specific config values
# into an L2Config object.

def upgrade_and_set_config:
# for each upgradeable L2 contract
# if that contract has initializer args, call `Proxy.upgradeToAndCall()`
# if it does not, call `Proxy.upgradeTo()`

def upgrade():
l2Config: L2Config = gather_config()
upgrade_and_set_config(l2Config)
```

### Gas limits for activation blocks

Upgrade transactions must be included in the L2 block and must fit within the block gas limit. Currently, there is only 1M gas guaranteed to be available for upgrade transactions. This limit comes from `_resourceConfig.systemTxMaxGas` being set to 1,000,000, which constrains the amount of gas available for system transactions including upgrades. While L2 block gas limits are typically set much higher in practice, the system only guarantees a minimum of `SystemConfig.minimumGasLimit()`, and the `systemTxMaxGas` constraint means upgrade transactions cannot exceed 1M gas.

The upgrade transactions described in this design will almost certainly exceed 1M gas, making the current limit insufficient.

**Solution: Additional Upgrade Gas**

Rather than increasing `systemTxMaxGas` (which would require L1 changes and still wouldn't be future-proof), we will implement a mechanism that guarantees sufficient gas for upgrade transactions regardless of the regular block gas limit. This will be achieved by introducing the concept of additional upgrade gas, which allows upgrade transactions to use gas beyond the normal `systemTxMaxGas` limit.

The implementation involves modifications to `op-node/rollup/derive/attributes.go` to handle upgrade gas allocation, similar to what can be seen in [this PR](https://github.com/ethereum-optimism/optimism/pull/14797). We should consider simply hardcoding the upgrade block gas amount very high (50M?),
and testing to ensure it is not exceeded by a future upgrade.

### Generating deterministic JSON NUT bundles

The NUT bundle will be generated via a solidity script. Similar to the
current go-defined NUTs, this script must be deterministic, and cannot depend on execution in an EVM. The implementation addresses will therefore need to be pre-computed as seen in [the go](https://github.com/ethereum-optimism/optimism/blob/2fdd97053ba8fb2bc27dcb38dcdc42ade7c8c78d/op-node/rollup/derive/ecotone_upgrade_transactions.go#L24-L25), although this will more likely be done using the CREATE2 or CREATEX deployer.

A major benefit of this approach is that it enables one to easily verify that a bundle of NUTs corresponds to a given monorepo commit, by simply:

1. checking out the commit
2. building the contracts
3. regenerating the bundle
4. verifying that it matches

The NUT bundle will be stored in the monorepo, will be generated by a `just` command, and tracked by git.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's important that the NUTs for a particular fork are generated on-demand by a just command so that we can control when we want to generate new NUTs. We can work out the details as we implement this, but maybe something like just nuts karst would generate and possibly override an existing NUT JSON bundle at file op-code/upgrades/nuts/nuts-karst.json and the JSON would have a field that stores the commit hash, and when CI detects a new or changed NUT JSON bundle it confirms that it can be generated from the commit. Although we need to solve the chicken'n'egg problem because we don't know the commit hash of the merge commit that merges the PR that updates the NUTs...


### L2 Contracts in scope

**The goal is to upgrade all proxied L2 contracts.**

It is anticipated* that this will be doable without significant changes to any of the contract implementations. Moving variables from `immutable` (in bytecode) to storage, and setting them in an `initialize()` function is not considered significant.

If more significant work is required which has not been anticipated, affected contracts will be removed from the scope and upgraded in a future milestone.

* *It may be the case that the ongoing FeeVault upgrade work presents difficulty for upgrading FeeVauts, in which case they would be excluded.*

### L2 contract versioning

**The goal is to release all L1 and L2 contracts under a single tag.**

This is already de facto the case because op-deployer generates genesis files with whatever L2 contracts are contained in the latest L1 contracts release. It is only because the L2 contract changes have not been rolled out to pre-existing chains that we haven’t officially included L2 contracts in releases.

Per the previous section, it is possible that not all L2 contracts are upgraded by this work, but even in that case, we should simply begin including all L2 contracts in each release, and aim to upgrade all L2 contracts in an upgrade as soon as possible.

### Upgrade testing

Testing of the upgrade path is absolutely necessary. This will be achieved using fork based tests similar to the existing L1 fork testing. For L2 upgrade fork tests, we will:

1. generate the upgrade bundle
2. fork an L2 chain
3. read and execute the upgrade bundle
4. execute the tests

The above steps will be runnable with a `just` command which should be a simple wrapper a `forge` command (no shell or go script required).
It is not necessary run L2 upgrade tests in CI for all OP Chains, but it should be easily to manually run the test the upgrade tests against an arbitrary L2 chain.

### Feature flags

**Features flagging THIS feature**

Features flags are not considered necessary for this feature, because it does not require significant changes to the L2 predeploys, and it will not be ‘activated’ until it is implemented in an upgrade.

**Supporting dev feature flags in upgrades**

The design proposed here must support feature flagging of L2 Predeploys, and should do so using the patterns described in [Smart Contract Feature Flagging & System Customizations](https://www.notion.so/Smart-Contract-Feature-Flagging-System-Customizations-28cf153ee16280e884c1f6fed632d5c8?pvs=21).
In order to achieve this, we can insert the `FeatureFlags` [contract](https://github.com/ethereum-optimism/optimism/blob/d09c836f818c73ae139f60b717654c4e53712743/packages/contracts-bedrock/test/setup/FeatureFlags.sol#L17) at some address using `vm.etch()`, and set the `devFeatureBitmap` using `vm.store()`.
The L2CM can then set a custom config values, or upgrade a contract to an experimental feature flagged implementation address based on the
values in the `devFeatureBitmap`.

This should work for all environments where feature flags must be supported ([foundry tests and alphanets](https://www.notion.so/Smart-Contract-Feature-Flagging-System-Customizations-28cf153ee16280e884c1f6fed632d5c8?pvs=21)).

### Configurable features

Any configurable production features are expected to be configued in the `L1Block` contract.
Just like with dev features, the L2CM will activate any configurable values, or set config specific
implementations based on the data available in `L1Block`.

## Risks & Uncertainties

- We do not have a good understanding of what code is running on the various L2s. This may lead to unexpected challenges.
- Other projects are occurring in parallel which will affect the predeploys.