# Ideas for rollup-geth integration into upstream **Date: 2025-04-07** In this document, I'd like to lay out some of the ideas for L2 forks of go-ethereum to work with the upstream code. At a high level, what we want to achieve is that L2 projects can use go-ethereum as upstream and have kind of a good time. Conflicts from upstream changes, especially larger ones, should be minimized. Common features across L2s, and even L2 common core features, should be implemented directly in the upstream where possible. L2 project authors should be able to collaborate via the upstream. ## What to do about rollup-geth, the repository The existing rollup-geth project at <https://github.com/NethermindEth/rollup-geth> has some features merged and others implemented as PRs. We won't just merge their project directly into upstream. We will need to collaborate with the rollup-geth development team in order to schedule submission and review of each change into upstream on an individual basis, and this process should also take into account the perspective of the intended consumers of rollup-geth, the L2 downstream projects. As such, we will have calls with these actors over a longer period of time in order to figure out the sequence of changes. It might make sense to create a new grant project with Nethermind after this planning has occurred, in order to carry out the code prep work for upstreaming. ## Long-term maintenance perspective of RIP implementations One of the unsolved problems of integration is the question of where the implementations of RIPs will actually be maintained, and whose job it is to do so. The current go-ethereum team is not able to take on the role of reviewing and integrating code for features that we do not fully understand. At most, we can eyeball the diff and make armchair comments about code quality or suggest very simple improvements. However, long term viability of L1-L2 code collaboration hinges on the existence of a shared repository where downstream implementers can propose changes and actually have them merged. I feel like what we need is similar to the Linux "staging tree" (<https://lwn.net/Articles/324279/>), i.e. a place where code is posted that isn't subject to the usual requirements, and that will never be compiled into production L1 geth. If we had a defined staging area, it'd be pretty simple for us to occasionally pull commits or even just files from another repository and sync them into the upstream, as long as only staging code is affected. Our whole job there would just be to ensure the staging changes compile and don't do anything obviously broken, like calling external binaries or whatever. Any changes that affect code outside of the staging area, 'framework changes' or just library additions, would have to be submitted through the normal go-ethereum contribution pipeline. When a RIP has marinated in staging for a while, and has been deployed on L2s in production, security researchers have had their look, and even L1 folks finally found the time to analyze the changes in detail, they can move out of staging into the main repo and be maintained there. This would also be the case if a feature was prototyped on L2s and enters the L1 EIP process. For L2-only things, an 'L2 production feature area' could be created within go-ethereum. In order to be able to have a staging tree and make it work, we need to create infrastructure that allows externalizing some protocol concerns into separate packages. The remainder of this document is about the technicalities of creating this infrastructure, but it's important to note that none of it exists today. The only effective extension point provided by go-ethereum upstream today is EVM precompiles, and even these need to be defined within package `core/vm`. As such, we will not really be able to merge any significant RIPs until we solve some of the issues below. ## Refactoring Projects ### Infrastructure for adding transaction types At this time, adding a new transaction type is not super easy in go-ethereum. There are two reasons for this: 1. Transaction types must be implemented in package `core/types`. All transactions are represented by [`types.Transaction`](https://github.com/ethereum/go-ethereum/blob/21b035eb29f6489ffee66d8ee2451873bc96dd2d/core/types/transaction.go#L55), which is an opaque, immutable type that can contain any type of transaction. In order to facilitate this extensibility it contains an instance of [`types.TxData`](https://github.com/ethereum/go-ethereum/blob/21b035eb29f6489ffee66d8ee2451873bc96dd2d/core/types/transaction.go#L75), an interface which is implemented by [a concrete type for each protocol transaction type](https://github.com/ethereum/go-ethereum/blob/21b035eb29f6489ffee66d8ee2451873bc96dd2d/core/types/tx_access_list.go#L48). The `TxData` interface is public, but its methods are private to the `core/types` package. This is because each new transaction type added into the protocol so far has added a 'facet' of transactions, like an access list, blobs, or most recently 7702 authorizations. For each of these facets, methods were added into the `TxData` interface. We have to keep the interface opaque to the public, and all the code in one package, in order to be able to do this. 2. Transactions are used throughout the system. go-ethereum contains an implementation of the Ethereum state transition, but also block production, p2p networking (mempool), a server for the standard RPC API, a client for the RPC API, and various developer tools for transaction creation and signing, such as hardware wallet support. All of these parts work with the same basic type, `types.Transaction`, but adding support for a new transaction type in package `core/types` does not magically make it work in all of these areas. Custom code needs to be added to support the new transaction type and its semantics across the system. When downstream L2 projects add a new transaction type, they need to make code additions across the entire codebase as described above. We want to make this process easier but it's unclear how it could really work. There are a couple ideas we had for solving point (1): - Create a generic 'L2 transaction' type that can hold opaque data. Doing it like this would mean the least changes for upstream go-ethereum but we also lose some feature flexibility this way. Part of the appeal of transaction types is being able to add new entry points into the system with their own respective features. And the tight integration through `types.Transaction` in go-ethereum means that adding a transaction type 'properly' will make it feel like a first class experience in the APIs, etc. - Create a publically-extensible version of `TxData`, thus allowing new types to be implemented outside of package `core/types`. This could lead to a cleaner separation of upstream and downstream code in L2 forks of go-ethereum. It's just very hard to implement this correctly in a way that keeps all the features working. There isn't anything quick we could do to solve point (2). We can't add support for 'any L2 transaction' in the RPC API unless we know what's the content of these transactions. And the intended modifications of state transition, EVM, block production of a new transaction type are usually pretty deep anyway, so it's nothing that can be implemented in a generic way. Ultimately, if we really want to make transactions extensible, we'd need to abstract away the 'facets' of transactions independently of their encoding and type. As an example, for 'access lists', we'd need to define a new facet type for the concept of 'access list', which can be added to a transaction. At the decoding stage, we'd figure out the facets contained in the transaction and then keep a list of them within the abstract transaction. Each 'facet' would live in its own Go package and define all of the functionality related to it, including p2p encoding/decoding, txpool validation, RPC-related logic. It might be a step forward, but note that even abstracting it to this degree will not save us from having to define custom handling for each type in some places. It's just not possible to abstract everything. It's also important to consider that abstraction always comes at a cost to performance. ### Infrastructure for extending the EL block header This is very similar to the point above about transaction types. Main difference with block headers is that they are defined as a [concrete struct type](https://github.com/ethereum/go-ethereum/blob/21b035eb29f6489ffee66d8ee2451873bc96dd2d/core/types/block.go#L75) in go-ethereum. This creates some unique challenges. Unlike the beacon chain implementation in Go, we have chosen to represent the blocks of all forks with a single type. This decision is amenable to the design of Go, since there is no need to duplicate any functions, or switch on the type of blocks. In order to allow for extensibility along the fork timeline, all block header fields defined by forks must be 'optional' in the sense that they will be set to a null value in blocks before the fork, and non-null after. There are only a handful of places in go-ethereum that actually create a block from scratch by setting the fields (more on that later), so it's pretty easy to ensure we always set them correctly. Likewise, the only place that really has to check that null-ness of fields corresponds with the forks is the [`core.BlockValidator`](https://github.com/ethereum/go-ethereum/blob/827d3fccf72b69324ba00f7050afd59cf956348d/core/block_validator.go#L71), which checks each block before it is imported. The RLP serialization of block headers directly follows the structure definition, and it enforces the simple basic check that for any optional header field which is set in the input, all preceding optional fields must also be set to a non-null value. This is sufficient to ensure the encoding of headers is canonical. Anyway, for L2 extensibility, the big problem with all this is that it introduces an additional fork timeline, but there is only one sequence of fields available in the struct definition. If an L2 wants to define its own header fields, they have to make them optional, but then they will conflict with any optional fields that we add later in L1. There are some ideas for solving this: - Make block headers opaque like `types.Transaction`, i.e. allow definition of a custom `HeaderData` object that is like `TxData`, then figure out how to make it possible to implement `HeaderData` externally. - Define a basic header that is like the existing L1 header, and a stub 'L2 header' that contains no fields. The stub would be the place where L2 additions are made in their code. We'd then need to add a mechanism for attaching an L2 header to a basic header somehow. - Similarly, create an 'L2 block sidecar' that can be attached to the block body. ### Refactoring the block transition implementation ("the thing") Across go-ethereum, we have ~10 instances of the code to run a block and all the transactions within. This code is sometimes referred to as "the thing". For an example, compare the [implementation of the `eth_simulate` RPC](https://github.com/ethereum/go-ethereum/blob/21b035eb29f6489ffee66d8ee2451873bc96dd2d/internal/ethapi/simulate.go#L196-L330) with the [`cmd/evm` transition tool](https://github.com/ethereum/go-ethereum/blob/21b035eb29f6489ffee66d8ee2451873bc96dd2d/cmd/evm/internal/t8ntool/execution.go#L146-L406). These two places do the same thing, just differently. It's not like all code is duplicated, the state transition function (i.e. single tx execution) and EVM are obviously shared, it's just the code around it and the setup of all the involved objects that exists many times. The reason for duplication is that each of these places has a different way of sourcing the actual block that's being executed, and they also differ in how the objects involved in block execution are configured. The way we construct the 'block' for running a test is different from the way blocks are handled in the normal engine API import flow, and it's different again in the block creation code (package `miner`). We also need to capture different intermediate results depending on context. Most of the differences there are historical, though, and we could probably turn this into a single object. This would give ourselves and the L2s a single place to implement any extensions that involve block execution. Features such as withdrawals and most recently the execution of system calls in the Prague fork are examples for functionality that would've been easier to implement if we had this refactoring in place. For the L2s, if a single shared implementation of the block transition existed, we could allow it to be extensible via certain hooks (i.e. pre-, post-tx, beginning and end of block). Having these hooks and a L2-specific block type should be enough to implement most L2 features. ### Fork configuration In go-ethereum we define the fork configuration in package `params`. There are a couple of types there: [`params.ChainConfig`](https://github.com/ethereum/go-ethereum/blob/21b035eb29f6489ffee66d8ee2451873bc96dd2d/params/config.go#L380) has the block numbers and timestamps of the forks, [`params.Rules`](https://github.com/ethereum/go-ethereum/blob/21b035eb29f6489ffee66d8ee2451873bc96dd2d/params/config.go#L1044) is the reified config for a specific block (fork on/off) and we also have an [enum type](https://github.com/ethereum/go-ethereum/blob/21b035eb29f6489ffee66d8ee2451873bc96dd2d/params/forks/forks.go#L23) with all the fork names. Adding upstream L2 support means we'd need to give them a place to plug their fork configuration into our system without conflicting with our definitions. This isn't really a problem in downstream right now, since it just involves adding struct fields. I do think it could be improved by refactoring everything to be based on the enum of fork names, turning the other config objects into maps. At this time, there is no way to activate individual EIPs across the whole stack in go-ethereum. We consider a chain fork to be an atomic unit that can be enabled or disabled, and forks are defined build on each other, i.e. they cannot be activated out-of-order. If a downstream project hacks around this, their client would always be at the risk of crashing unexpectedly, since all the upstream code is written against the assumptions of L1 fork ordering. I don't expect that we'll want to change this aspect of go-ethereum for L1. EIPs will always be shipped in a specific fork and may also depend on each other. However, it's useful to consider individual activation of certain L2-related features/RIPs, and to try and create a configuration system that makes it possible. ### EVM extensions: opcodes, precompiles The EVM is arguably the easiest place to extend go-ethereum since it already uses a well-encapsulated interface, and it's set up for extensibility on an individual EIP level, unlike other parts of the system. So it is no surprise that many proposals for changes will be made there. The EVM is also the area that's closest to actual users, so any changes there will have an immediate impact. While we have no trouble shipping non-activated precompiles and opcodes in go-ethereum, it'd still be nice for the long term to refactor things in order to permit externalizing definitions, to permit staging implementations in a central place. ## Things that will never be extensible As a final note, I believe it is best if some areas are just declared off-limits for L2 forking, or at the very least, we will not provide any support if a project decides to modify there. These areas include: - State implementation, i.e. packages `core/state`, `trie`, `triedb` etc. We change stuff there all the time, and it will be impossible to allow optional extensibility of this code. - `core/txpool`, beyond the basic interfaces provided. If an L2 requires special treatment of a specific transaction type in `core/txpool` and its sub-packages, they would have to submit an API extension through the upstream contribution process. - Basic EVM semantics, i.e. adding things like EOF, multidimensional gas, anything like that. This is partly a political decision, but we do not want the L2 process to create fundamental incompatibilities with the L1 EVM. It's also very very hard to abstract changes at this level.