How Hardhat 3 will ensure precise simulation for L2s using EDR
Devcon 7 SEA·Thu, Nov 14, 2024, 11:53 AM · 21:53
Speaker
As the Ethereum ecosystem shifts towards L2 solutions, developers encounter new challenges in maintaining consistency and efficiency across different chains. Hardhat is powered by EDR, a reusable Ethereum Development Runtime implementation to build developer tools. This talk will show how EDR's support for L2s in Hardhat 3 will streamline the development process, improve testing accuracy, and enhance the overall developer experience.
Transcript
Welcome everyone and thank you for staying until the end of the day. I am Wodan, a developer at the Nomic Foundation, and today I'll be talking about how Hardhat 3 will ensure precise simulation for L2s using EDR. There's a lot to unpack there, but first I wanted to comment on the Nomic Foundation. Maybe not everyone's familiar with it, but you're probably familiar with our work. We are a non-profit dedicated to Ethereum developers, you guys, and our most well-known product is likely Hardhat and the Hardhat VS Code extension.
That's us. So an overview of what I'll be discussing today is I'll start off with a quick introduction to what EDR is. Then I'll go into variability between L2s. I'll look at some problems that currently exist when developers are developing for L2s using L1 tooling. Then I'll do a technical deep dive into how EDR actually simulates L2s accurately.
This will also be interesting for any of you L2 developers out there, as I'll talk about the extensibility points that EDR will expose in the future. And finally, I'll touch upon a demo that shows how L2s work in Hardhat 3. This will be interesting for all of you Hardhat users, as it will show how the technical complexity of all of this variability is boiled down to a simple and straightforward user experience. So what is EDR? EDR or Ethereum Development Runtime in full is an reusable EVM development runtime library for tooling.
It is a set of building blocks for a blockchain simulation and in particular it allows you to observe EVM and Solidity execution. So as such we are targeting smart contract development, the simulation, testing, and debugging thereof, and we're not targeting to be an execution layer node. If you're curious to learn more about EDR, earlier this year we had an EDR launch announcement when we integrated into Hardhat 2, and there's more information about other features, performance improvements that brought to Hardhat, and future roadmap for EDR as well. So what variability exists between L2s and L1? For the sake of this presentation and also for the implementation of EDR, we're assuming L2s that are EVM equivalent.
This means that they have to comply with the EVM specification or the Ethereum white paper, if you will. When we can depend on roll-ups, any of the L2 transactions can have their own custom types. This means that when we're dealing with custom L2 transactions, that there is logic that's different, which is executed within the EVM. The way that we're dealing with rewards, the way we're dealing with fees can be different, and even the output that's returned can be different. For example, halt reasons when an exceptional halt occurs.
When we go into a transaction and we look at the bytecode, the opcodes might also be different. It could be that an L2 doesn't support all of the opcodes of L1 or vice versa, but it can even be that the same opcode has a different type of behavior in the L2. For example, the block number opcode, how would we simulate this on an L2? Do we give a prediction of the L1 block that we expect to be included in, or do we return an L2 block number. Within the EVM, another thing that's different are precompiles.
The set that's available in different L2s differs, as well as them being different from L1. Another thing that is different are hard forks. Every L2 will have a different set of breaking changes for which they'll have their own hard forks and something that we need to track as well is so-called hard fork activations. Those are the block numbers or timestamps at which a particular hard fork becomes activated and these will for example be needed when you do an ETH call at a particular block number. If we're forking a blockchain and we want to run a historic block, we need to know what hard fork should be activated at this point.
When we then roll up everything into a L2 block, we also need to at the protocol level take into consideration fee calculation and we need to incorporate custom transaction receipts. When we are deploying our own chain for specific L2s, we need to consider their own pre-deployed contracts. These are incorporated in the Genesys state and mean that you can access these contracts at a predefined address. These differ per L2. And then finally, if we go up one more layer, you have RPCs, the RPC, which might have additional fields for methods.
It could be that a method returns fields, but with a different behavior. And it can even be that one of those methods has entirely different logic altogether. All of these types of variability need to be incorporated and keeping track of them is a huge pain. So a big shout out to EVM diff and the L2 documentation that was very instrumental when we were implementing the OP stack for EDR. So we have an idea now of what variability exists, but what problems might occur?
Here are some examples. So when we start off at the execution layer, when we're dealing with unknown transaction types from an L1 perspective, we're not sure how to actually execute them. It could be that they throw an error or it could be that they do execute but because the opcodes have different behavior, the result is different and as such the L2 execution will be incorrect within your L1 tooling. When we're then trying to mine a full block, we also run into issues. The RLP encoding for these unknown transactions is unknown, which would result in an incorrect tri-route.
And it could be that the header has different header fields, which would also result in an incorrect block hash. Then when we look at the gas calculation these end up being incorrect as well. Let's have a look at the way that L2 transaction costs are structured. So on the left side you'll see something that's very familiar. We have our execution gas cost consisting of a gas price multiplied by gas used.
This is the same as on L1 except that L2 gas prices will be lower. But we have something new which is the L1 data fee. This is the cost we have to pay for the roll-up or the part that our transaction is within that roll-up. This is the L1 gas price multiplied by the L1 gas used multiplied by the number of bytes of transaction data. Usually, this is compressed to reduce the amount of memory usage.
But we somehow need to convey this cost to the user. L2s do this differently. It could be that they try and convert this factor of gas price multiplied by gas used to an L2 gas usage. Or it could be that they somehow try to change this to the L2 gas price. Each L2 will have their own strategy for doing this.
Then when we're looking at debugging, for example, using debug trace transaction, what we're trying to do is we replay some block on the chain until we reach the transaction that we want to debug. And it could be that up until that point, we find some unknown transaction, and we could treat them as an EIP-155 transaction or a legacy transaction, and try to execute based on the best effort. But this might result in errors. So instead, we could choose to skip it. But this might have a negative side effect that if that transaction affected the state that our contract is also accessing, that we are getting a different result than we would on a test or mainnet.
All of these examples that I gave have something in common. We're trying to build L2 smart contracts using L1 tools and hoping that it just works. It could be that the tests are passing but there are still subtle execution differences that give us a false sense of security. And this leaves room for security vulnerability once we deploy. So how does EDR circumvent this and accurately simulate L2s?
So here we have an overview of the different building blocks that I mentioned outlined in black. In orange we have entry points from and into Hardhat and in purple we have our EVM which is the EVM dependency that we use. Everything outlined in green is parts that we previously had supported for L1 Ethereum but now we need to convert to be able to be multi-chain and also support different L2s. I've numbered it in two sections as they both have different requirements and we'll delve into those respectively now. So the part outlined in one was all Rust code.
So we need to look at expansibility from a Rust perspective. For this we had several requirements. We wanted compile-time polymorphism. This would allow users of our crates or packages, if you will, to be able to use these interfaces or traits during compile time to generalize their types and functions. We also wanted to generate type errors at compile time.
This would force L2 developers to resolve any issues with their typing, as opposed to the error bubbling up to hardhat users at runtime. Finally, we also wanted to ensure that their type definitions were reusable from a base chain to a L2 chain. For example, if we have an EIP2930 transaction, this is used in OP stack as well. So being able to reuse those types lightens the burden for L2 developers. The solution we used are the Rust traits and generics.
Traits are a form of interface that can be used both at runtime and at compile time to constrain generics. And generics are just a way to generalize function definitions and type definitions across a type. For each of these traits or interfaces, we associated types with them that we consider to be a chain specification. Think of a transaction type, a block type, etc. And there are some constants which are used within the protocol.
We distribute individual change using Rust crates for reusability. So if we look at the overview again here, and we start at the top right, we have a remote network clients which does RPC calls to a provider like Infior or Alchemy. Here we introduced an RPC spectrate that would define the RPC transaction or an RPC receipt, etc. Then when we go to REVM, here we introduced something called EVM wiring. We proposed changes to REVM and with the graceful help of Dragan Rakita, the maintainer, we were able to incorporate these large changes into REVM, which means means now within our EVM you can also run and extend different chain types.
Here you would define a runnable transaction, a block within which it's executed, the hard fork, Hall's reasons, etc. Then we go up one level to something we call the executor. The executor is a wrapper around the EVM, which receives a signed transaction, passes it in, and while it's executing, we gather additional data, which we use for runtime observability. So things like traces for a stack trace, etc., that we expose to the end user.
Here we introduce a type runtime spec and then when we're incorporating all of these transactions into a block within our block builder we need to consider parameterizations for the protocol level such as the base fee calculation. This has specific constants that need to be incorporated which differ between L1 and L2 And here we introduced a ETH header constants interface to define those. Then all of this logic is tied together within the node or provider. And here we introduced a provider spec with things such as a pool transaction. For example, for blob transactions, we also keep track of additional blob data, etc.
And these are stored within the mempool. Then finally, we reach the RPC interface that's exposed to HardHat. Here what we do is we use a tool called NAPI, which can be used for generating TypeScript bindings from Rust code. And this allows us to basically from HardHat, which is TypeScript,ings from Rust code, and this allows us to basically from hardhat, which is TypeScript, call our Rust functions. And here we introduced a sync and API spec.
Sync basically means we're trying to call our Rust code from a threaded environment, and this ensures that the access to that data is correct. And we also do a conversion from compile types to runtime polymorphic types, which we'll have a look at next. All in all, this encompasses six different traits or interfaces, and these are all the things that an L2 developer would have to implement in order for all of the building blocks that we have within EDR to be supported for their chain, and in addition for their chain to be usable within Hard Hat 3 in the future. So when we look at the part that was outlined on the left, we're dealing with TypeScript, so the requirements for extensibility are slightly different. We're dealing with runtime polymorphism.
We're trying to minimize memory usage and load times. The goal here is that we shouldn't have to load all the possible L2 chains, only the ones that the user of hard hat in that particular configuration wants to use. And we want to avoid centralization of chain types. We don't want a repository where you have to add your specific chain type to an enum. Instead, we want you to just be able to plug and play your own chain types independently.
For this, we created an NAPI wrapper around dynamic trade objects. Dynamic trade objects are basically virtual objects where you can access a specific instance of an object through an interface. And to distinguish chain types, we use a string identifier. And then finally, for distribution, we're using NPM packages. Here we have an overview of what that looks like on the TypeScript side.
We have a, if we go from right to left, we have a provider interface that receives a generic JSON RPC request and returns a generic JSON RPC response. We have two different implementations, one for L1, one for Optimism. These would basically parse the input, make it a typed request for their respective backends, handle it, and then again convert the response to a generic string, which is returned to the user. In order to be able to construct these, we're just using a factory pattern. So if we move one column to the left, we have a provider factory, which would receive a generic configuration, again using strings, and we have two implementations that would parse those configurations and make them typed and construct a respective provider depending on L1 or Optimism.
If we then move one column further to the left, we have something called a context. This is maintained from the start until the shutdown of your application and contains a registry of provider factories, which is a mapping of the string identifier to the instance of the provider factory. If we look at a usage example, you start your application and would register any of the requested providers based on a configuration and you store them in the registry. Then when a hardhat user would actually request to create a provider, let's say Optimism, we do a lookup in the registry, find the Optimism provider factory, pass on the config, and ask it to create an instance of the Optimism provider, which is returned to the user. This is the way that we have designed extensibility.
For the hardhat beta beta we'll be releasing with the OP stack and l1 support in the future we'll also be releasing all of these api's and it would allow you guys to extend it for your own l2 now let's have a look at what this actually looks like in hardhat 3 that got a little bit complicated on a technical level but but from a user side it's actually quite straightforward to use. So here we see an example of a hardhat3 user config. At line 7 we have two networks defined, EDR-L1-Sypolia and EDR-Op-Sypolia. They both have the EDR type for the back-end simulation. The first one has a chain type for L1, whereas the second one has a chain type for Optimism.
This is communicating to EDR which typing system to use. And when we switch to a hardhat script, we see on line 3, we're requesting the hardhat network manager to connect to the previously configured Opsipolia and we specified that for its types it needs to use Optimism. This is informing the TypeScript type system that the estimate L1 gas function needs to be available which would only exist for Optimism and not for L1 and we make a call to it through the public client and then we send an L2 transaction to L1, and we make a call to it through the public client, and then we send an L2 transaction to transfer one way, and we wait for the transaction received to be included in a block. So when we execute this, we're estimating L1 gas, get 1600, and then we see that our transaction that was sent is included in a block. If we then go back and we switch the network we are connecting to to EDR L1 Sopolia and use L1 typing, we see that we're getting a type error for estimate L1 gas as this is not available for L1 networks and the Veeam object knows this.
So this is a sneak peek at some of the features that will be available with multi-ain and the kind of simple user experience that you get as a HardHat user. If you want to learn more, we've had several other talks already at DEF CON. We had a talk about the future vision of Nomic Foundation and the products that we're developing. We also had a talk which was a preview for Hardhat 3. And tomorrow we'll have a talk by the Slang team.
Slang is a compiler as an API for developer tools. And this is basically useful for writing your own linters, doing formatters, etc. So if you're curious about that, go have a look at them tomorrow. Thank you. Alright, so we do have some extra time for QA.
Feel free to scan the QR code if you have more questions for Walden. In the meantime, let's take this one. ARP has pre-deployed contracts that do not exist as EVM code on chain. Instead, instead is Go code embedded into Get. Any efforts to handle this?
So the way that we could theoretically handle that is instead of having it as Go code, we could include this in the genesis state that is deployed when you're using an arbitrum L2 chain. Alright, so let's when you're using an Arbitrum L2 chain. All right. So let's wait for a few more seconds. We have another one here.
Wenhar had that three. I think we don't have an announcement date yet, so stay tuned. We have an internal alpha going at the moment, but the beta will be sometime at the start of next year, but there's no definitive date yet. How does EDR compare to Foundry's Anvil? The hardhat simulation or the simulation component of it and the RPC that's exposed is comparable, like identical in that sense to Anvil.
The performance is also very comparable. We have a slightly different way of maintaining our state, and as a result of that, if you're very often switching between different states, so going and doing EVE calls and jumping back and forth between a previous state, making some modifications and jumping back. In those use cases, we're often faster. But from a use case perspective, they're the same. The only difference that we might have is we haven't released our crates yet, which I think Foundry or Anvil has.
But in the future, we'll be releasing a first version of EDR as well as crates on. Let's remain on the stage for a little while to see if we have more questions. SPEAKER 1 COOKIE MONICA DUNCAN- All right, if you have any questions, you can also meet me afterwards. And our CTO is also here who can answer any questions about Hardhat3. Alright, that would be great.
Thank you guys so much and let's give a round of applause to Ruwanan.
Automatic transcript — names and jargon may be misspelled.