Customizing the EVM with Stateful Precompiles
If you’re interested in building your own customized instance of the EVM, then this is your lucky day. Stateful precompiles offer a new interface for developers to add functionality to their EVM instance without writing a single line of Solidity.
Stateful precompiles build on the notion of precompiles in the EVM by adding state access, allowing a wider variety of functionality.
In this article, we’ll go through how we extended them so that you can use a stateful precompile to implement added native functionality to the EVM while interacting with the precompile in the exact same way that you would a normal smart contract.
Stateful precompiles can be used to implement on-chain VRFs, mint the native coin of an EVM instance, or make smart contracts more performant and gas efficient.
The possibilities are endless. The whole point of stateful precompiles is to put power in the hands of developers.
This article will dive into what a stateful precompile is and the potential functionality that they offer for customizing the EVM. For a full walkthrough on implementing your own stateful precompile, stay tuned for a more in-depth tutorial coming soon.
What is a Precompile in the EVM?
A precompiled contract is a simple program that implements the following interface:
This takes in a byte slice and returns two values:
- The amount of gas required to perform the operation
- The result of running the precompile on the input
Precompiles are used to implement cryptographic operations and other functions that would be difficult and inefficient to implement with Solidity.
Precompiles are stateless and typically leverage existing libraries. Below is an example of a precompile implementation written in Go. It implements the SHA256 hash operation using just a few lines of code. This example is written in Go, but Precompiles can be written in whichever language was used in a given EVM implementation.
How are Precompiles Used?
Smart contract developers can invoke precompiles with predefined keywords (in the example below, sha256). This allows developers to utilize crypto libraries without implementing it themselves (don’t roll your own crypto).
Under the hood, precompiles are invoked by CALL opcodes using designated addresses. Any CALL opcode (CALL, STATICCALL, DELEGATECALL, and CALLCODE) can invoke a precompile. Normally, these opcodes are used to call a smart contract, and the input passed in represents the encoded parameters to the smart contract call. In the case of a precompile, though, the input is passed directly to the precompile to perform the operation and subtract the required amount of gas from the transaction’s execution context.
How do we build a Stateful Precompile?
A stateful precompile builds on the notion of a precompile by adding state access. This unlocks a wide range of potential use cases for customizing EVM instances.
Alright, let’s start building.
We’ll start off by talking about function selectors. Each EVM transaction has a data field. When you issue a transaction to a decentralized exchange (DEX), you’re calling a function on the DEX smart contract, and the function selector specifies what function to call. The function selector and the arguments to the function comprise the data field of the transaction. Additionally, smart contracts can invoke each other in the same way using the CALL opcode. In this case, the function selector and its arguments are encoded as the input data to the CALL operation.
The first four bytes of the KECCAK256 hash of a function’s signature is its function selector.
For example, if you have the following function:
Then its function selector will be keccak256(“setNone(address)”)[0:4] and the 20 byte address will be packed into a 32 byte slot, such that the input data will be a total of 36 bytes.
By imitating function selectors within our stateful precompile, we can add a router for the functionality of the stateful precompile, so that it can be called in the same way as a contract built in Solidity:
Each statefulPrecompileFunction represents a single smart contract function and has:
- A 4 byte function selector based on its signature
- An execution function
By injecting state access into the execution function (a new addition from the original precompile interface), stateful precompiles can maintain their own state space as well as interact with the EVM’s state. Now that we have defined this interface to route smart contract calls to their respective functions, all that we need to do is define the execution functions in Go.
Since we’re no longer limited by the original precompile interface, which only took in a single byte slice as input, these execution functions will have complete access to the EVM state, and can be used to implement a much wider range of functionalities. In fact, anything that you can build in a smart contract, you can also build in a stateful precompile.
The reverse, however, is not true. Since stateful precompiles are defined by the developers of a customized EVM, they have much greater flexibility and privileges (which comes with great responsibility for their developers). Stateful precompiles can modify balances, read/write the storage of other contracts, and could even hook into external storage outside of the bounds of the EVM’s merkle trie (note: this would come with repercussions for fast sync since part of the state would be moved off of the merkle trie).
The only real limitation for stateful precompiles is that their execution functions must be deterministic so that every node in the network will compute the same result.
What Have We Built?
In case you’re not excited yet, let’s go over what we’ve built.
Anything that you can build in a smart contract, you can now build in a stateful precompile.
We can build stateful precompiles in the same language that implements the EVM (Go in the case of subnet-evm), making them faster and cheaper than programs implemented in Solidity and executed within the EVM interpreter.
Stateful precompiles exist outside the bounds of Solidity and do not execute on the EVM. This means that they are not bound by the EVM’s rule set and allow developers to extend the EVM’s functionality while seamlessly integrating into the EVM and maintaining complete compatibility with smart contracts built in Solidity.
Since we built an interface that uses function selectors in the same way as Solidity, you can write a Solidity interface contract to match the interface of your stateful precompile and leverage existing tooling to interact with your stateful precompile out of the box.
This puts the power and responsibility in the hands of application level developers in a unique way.
To reiterate, this is a dangerous capability and should be used carefully. If a developer builds a non-deterministic stateful precompile, execution of their modified EVM will naturally be non-deterministic. This could result in different validators disagreeing about whether a block is valid or not. If this happens, then their chain would most likely halt when validators cannot agree on the state of the network.
Let’s Implement a Contract Deployer Allow List Using a Stateful Precompile
To illustrate how we can use stateful precompiles, let’s look at one particular use case that has been one of the most requested new features for blockchain-based games: limiting who can deploy a smart contract.
Many blockchain-based games want to launch their own network with their own currency, validators, and fee parameters. They may also want to limit usage of the network to their game only. To accomplish this, we’ll implement an allow list precompile to provide a configurable, customized EVM to restrict contract deployments.
The allow list will explicitly mark which addresses are allowed to deploy contracts. Only addresses marked as either Admins or Enabled will be allowed to deploy a smart contract. Lastly, only admins will be allowed to modify the allow list.
First, we’ll create the Solidity interface that we want to implement. We’ll add functionality for admins to update the role of any address as well as read functionality to fetch the role of any address.
We don’t need to actually implement the contract, since the precompile itself will provide the functionality, but we define the interface in Solidity as a reference and so that we can interact with our precompile via Remix later.
Now let’s look at what it takes to implement the ability for an admin to update the allow list:
This function creates an execution function to modify the allow list. This execution function:
- Checks that there is sufficient gas to perform the operation
- Confirms that the caller of the function has the admin role
- Updates the state space for the precompile address’ state to reflect the modified role for the desired address
We can use this helper function to generate execution functions for setting the allow list role to any of the following: Admin, Deployer, and None.
For our last execution function, we will provide read access to the current state of the allow list:
Now that we’ve implemented all of the actual functionality we want for our precompile, we can put it all together with the desired function selectors and gas costs to create the actual precompile contract:
The signatures above match the ones that we included in our Solidity interface. This means that when we deploy that interface at the precompile’s address in any 3rd party tooling, the input data for our transaction will match exactly what our precompile expects and smart contract calls will be routed to the corresponding execution functions.
The last step is to make a minor, optionally configured modification to the EVM to require that if the contract deployer allow list is enabled, the EVM will confirm that a transaction has the correct permission to deploy a contract within the CREATE opcodes:
Using the Precompile from Remix
You can copy paste the Solidity interface from: https://github.com/ava-labs/subnet-evm/blob/master/precompile/allow_list.sol into Remix and compile it by hitting “Compile allow_list.sol”.
Once you’ve compiled the interface, you can navigate to the Deploy tab in remix, select “Injected Web3” for your environment so that you can interact with your EVM instance, and paste the address “0x020000000000000000” of the precompile in the field to the right of “At Address”.
Clicking “At Address” will deploy the interface at that address, as if you had deployed a fully implemented contract in Solidity and from there you can interact with the precompile directly in Solidity.
What’s Next for Stateful Precompiles and the Subnet-EVM?
We hope that the entire EVM community will find new value in stateful precompiles and the potential to seamlessly integrate new functionalities into the EVM.
If you have an idea for a stateful precompile that may be useful to the community, feel free to create a fork of the subnet-evm and create a Pull Request.