Unlocking Predeploy Upgrades for the OP Stack with NUTs
The Ethereum protocol has precompiles, programs that live at specific addresses, which when called execute code which is written in the native language of the client implementation (ie. go, rust, python, etc).
The OP Stack has all the same precompiles as Ethereum, but it also has a concept of “Predeploys”. These are smart contracts, written in Solidity, which are included in the L2 Genesis state of every OP Stack chain. Just like our L1 contract system, these contracts are upgradeable using an ERC-1967 proxy. Unfortunately, while our L1 contracts have well established upgrade infrastructure and have been regularly updated in previous upgrades, our Predeploys have lacked similar infrastructure. The consequence is that these contracts have rarely been upgraded in the past, and we have avoided implementing features which would require it.
This post lays out how we finally got past this sticking point, and illuminates some of the internals of the OP Stack.
How Predeploys work
When the L2 genesis state is generated for a new chain, the bytecode of Proxy.sol is etched into the 2048 addresses, running from 0x4200..0000 to 0x4200..0800. The vast majority of those proxies have no implementation address set in storage, they are simply set aside to provide ample usable space for future predeploys. In practice we currently have 27 Predeploys which are actively maintained.
The predeploy proxies all have the same admin, which is a ProxyAdmin contract controlled by a multisig account on L1.

Challenges with predeploy upgrades
The fact that the ProxyAdmin is controlled by a multisig is what makes upgrading these predeploy contracts challenging. While it is possible to insert transactions from L1 to L2, it is operationally quite difficult to do across many different instances of the OP Stack. In order to do so we would need to have a transaction signed by the multisig which controls each OP Stack chain and not only that, but those transactions would need to make 27 different calls in order to upgrade all of the L2 contracts.
Since we are operating many different instances of the OP Stack, and every multisig transaction requires careful construction and validation to ensure it is safe to execute, it’s simply not feasible to safely make so many transactions.
Another challenge comes from the fact that some predeploys need to be upgraded in the same block at which a hardfork is activated. An example of this is the L1Block contract, which receives a call at the start of each L2 block in order to inject data from L1.
When new values are added to the L1Block contract, it needs a new setter function to receive that data. For example the Jovian hardfork added a new setL1BlockValuesJovian() function, and that function needs to be available in the very first block (rust, go) after the Jovian hardfork.
Thus we have two key requirements we need to fulfill with our solution:
- Predeploy upgrades must not require a multisig transaction
- Predeploy upgrades must occur within the first block of a hardfork
Seeing predeploy contracts as a first class citizen of the protocol
In order to understand our ultimate solution, we need to stop thinking about predeploys as if they are any old contract, and start viewing them as an integral part of the protocol. From that perspective, we will see that we are able to insert a series of hand crafted Network Upgrade Transactions (NUTs) which execute the upgrade.
In fact, this is how the L1Block contract has been upgraded in previous hard forks, by directly injecting the initcode (go, rust) of the contract, then upgrading the proxy to the resulting implementation address (go, rust).
Aside: You might be wondering how the upgrade transaction is able to succeed without coming from the actual admin address for the proxy. This is due to a very fortunate bit of code we included which also treats the zero address as an admin. The original motivation was to enable off-chain read operations from the zero address, but it has turned out to be incredibly useful for our purposes here.
However this approach has its own difficulties, which our solution must also overcome:
- It is difficult to verify that the hardcoded bytecode corresponds to a particular version of the L1Block contract’s source code
- Each contract requires two transactions to deploy the implementation and then update the pointer in the proxy contract. Doing this for anything more than a small number of contracts would require a massive amount of boilerplate and hardcoded byte strings.
- The previous two items create a significant risk of a consensus failure between the consensus layer clients (
op-nodeandkona), which could result from even a single byte of difference between the transactions they each inject.
The Solution
So, our solution needs to take advantage of this ability to inject NUTs at the hard fork activation block, without requiring a bunch of hardcoded bytecode.
What we landed on we call the L2ContractManager (L2CM), which is a framework enabling us to upgrade all of the predeploy contracts within a single L2 Block. In order to achieve this, a script runs in our monorepo which generates a JSON file (aka a NUT bundle), that defines the set of NUTs for the upgrade.
The upcoming Karst upgrade includes 31 distinct transactions, and so we can follow the Karst NUT bundle as a tour of the new upgrade process.
What’s in the bundle?
If we print the intent fields of the bundle,
cat op-core/nuts/bundles/karst_nut_bundle.json | jq -r '.transactions[].intent'we can see what each transaction does:

The output is highlighted with different colors and numbered, so that we can go into a bit more detail about what happens in each of the transactions.
NUT Group 1 - ConditionalDeployer Setup
These transactions deploy a new ‘ConditionalDeployer’ contract implementation, and upgrade a previously empty predeploy proxy to point to it. This new contract is a thin wrapper around Arachnid’s DeterministicDeploymentProxy; all it does is prevent the transaction from failing if the new implementation code would land at an account which already exists. This is a very nice property, because it allows us to simply upgrade every contract, without having to consider whether or not its initcode has actually changed.
These Group 1 transactions are required in Karst, so that the deployment transactions in Group 2 can succeed. They will not be included in future hardforks.
NUT Group 2 - Implementation Deployments
This is a series of transactions, to deploy each of the implementations. Each is a call to ConditionalDeploy.deploy().
NUT Group 3 - L2ProxyAdmin upgrade
This is a single transaction which upgrades the L2ProxyAdmin, giving it a new upgradePredeploys() function which we’ll need to finalize this upgrade. As with Group 1, we only need to include this in Karst in order to enable the L2CM system.
NUT Group 4 - L2ContractsManager Deployment
This transaction deploys the new L2ContractsManager contract. This contract is not proxied, and is intended only to be used a single time during this upgrade process. The addresses of the newly deployed implementation contracts are stored as immutable variables, so that they affect the initCode of the contract. Therefore each hardfork will have its own L2ContractsManager (unless the upgrade does not modify any predeploy code at all, in which case it would have bytecode matching a previous L2ContractsManager, and so that L2ContractsManager would be reused and effectively be a no-op).
The L2ContractsManager is called in the final transaction, so we’ll describe what it does there.
NUT Group 5 - Call L2ProxyAdmin.upgradePredeploys()
Finally, we arrive at the transaction which enacts the upgrade, updating the implementation point in every predeploy and ensuring it is properly configured.
The call chain is:
L2ProxyAdmin.upgradePredeploys()L2ContractsManager.upgrade()(viadelegatecall)
The delegatecall is the key detail. L2ProxyAdmin is the admin of every predeploy proxy, but the logic for the upgrade lives in the freshly-deployed L2ContractsManager. By delegatecalling into L2CM.upgrade() it is as if the ****L2ProxyAdmin itself is executing the instructions defined in the L2CM.
Inside L2CM.upgrade(), two high-level things happen:
- Config gathering: in this step the current configuration values are read from all of the pre-existing live predeploys, so that no on-chain state is lost across the upgrade. This is necessary because some of the config values were previous stored as immutable values in the implementation code, so we need to collect them and inject them into Proxy storage in the next step.
- Update implementations and set config: on each predeploy, either
upgradeToorupgradeToAndCallis called. This points the proxy at its new implementation and if necessary calls the corresponding initializer with config values gather in the previous step.
When this transaction completes, all 27 predeploys are running their new implementations, correctly configured — and the hardfork block proceeds as normal. No multisig was involved, and not a single byte of implementation bytecode was hardcoded into the consensus clients.

Conclusion
For most of the history of the OP Stack, we have lacked the ability to safely and smoothly upgrade our predeploys, which prevented us from improving those contracts.
The L2CM has finally unlocked this ability, and is already enabling us to make faster progress on new functionality.
Authored by
Maurelian
