New Ethereum talks, every Monday. The week's conference uploads by event, in your inbox.

How Hardhat 3 will ensure precise simulation for L2s using EDR

Devcon 7 SEAThu, 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

Automatic transcript — names and jargon may be misspelled.