Let's jump to helloworld/module.go file first. This file contains the module definition for our precompile. You can see the ConfigKey is set to some default value of helloWorldConfig. This key should be unique to the precompile.
This config key determines which JSON key to use when reading the precompile's config from the JSON upgrade/genesis file. In this case, the config key is helloWorldConfig and the JSON config should look like this:
In the helloworld/module.go you can see the ContractAddress is set to some default value. This should be changed to a suitable address for your precompile. The address should be unique to the precompile. There is a registry of precompile addresses under precompile/registry/registry.go.
A list of addresses is specified in the comments under this file. Modify the default value to be the next user available stateful precompile address. For forks of Subnet-EVM or Precompile-EVM, users should start at 0x0300000000000000000000000000000000000000 to ensure that their own modifications do not conflict with stateful precompiles that may be added to Subnet-EVM in the future. You should pick an address that is not already taken.
Don't forget to update the actual variable ContractAddress in module.go to the address you chose. It should look like this:
Now when Subnet-EVM sees the helloworld.ContractAddress as input when executing CALL, CALLCODE, DELEGATECALL, STATICCALL, it can run the precompile if the precompile is enabled.
Search (CTRL F) throughout the file with CUSTOM CODE STARTS HERE to find the areas in the precompile package that you need to modify. You should start with the reference imports code block.
The module file contains fundamental information about the precompile. This includes the key for the precompile, the address of the precompile, and a configurator. This file is located at ./precompile/helloworld/module.go for Subnet-EVM and ./helloworld/module.go for Precompile-EVM.
This file defines the module for the precompile. The module is used to register the precompile to the precompile registry. The precompile registry is used to read configs and enable the precompile. Registration is done in the init() function of the module file. MakeConfig() is used to create a new instance for the precompile config. This will be used in custom Unmarshal/Marshal logic. You don't need to override these functions.
Module file contains a configurator which implements the contract.Configurator interface. This interface includes a Configure() function used to configure the precompile and set the initial state of the precompile. This function is called when the precompile is enabled. This is typically used to read from a given config in upgrade/genesis JSON and sets the initial state of the precompile accordingly. This function also calls AllowListConfig.Configure() to invoke AllowList configuration as the last step. You should keep it as it is if you want to use AllowList. You can modify this function for your custom logic. You can circle back to this function later after you have finalized the implementation of the precompile config.
The config file contains the config for the precompile. This file is located at ./precompile/helloworld/config.go for Subnet-EVM and ./helloworld/config.go for Precompile-EVM. This file contains the Config struct, which implements precompileconfig.Config interface. It has some embedded structs like precompileconfig.Upgrade. Upgrade is used to enable upgrades for the precompile. It contains the BlockTimestamp and Disable to enable/disable upgrades. BlockTimestamp is the timestamp of the block when the upgrade will be activated. Disable is used to disable the upgrade. If you use AllowList for the precompile, there is also allowlist.AllowListConfig embedded in the Config struct. AllowListConfig is used to specify initial roles for specified addresses. If you have any custom fields in your precompile config, you can add them here. These custom fields will be read from upgrade/genesis JSON and set in the precompile config.
Verify() is called on startup and an error is treated as fatal. Generated code contains a call to AllowListConfig.Verify() to verify the AllowListConfig. You can leave that as is and start adding your own custom verify code after that.
We can leave this function as is right now because there is no invalid custom configuration for the Config.
Next, we see is Equal(). This function determines if two precompile configs are equal. This is used to determine if the precompile needs to be upgraded. There is some default code that is generated for checking Upgrade and AllowListConfig equality.
We can leave this function as is since we check Upgrade and AllowListConfig for equality which are the only fields that Config struct has.
We can now circle back to Configure() in module.go as we finished implementing Config struct. This function configures the state with the initial configuration atblockTimestamp when the precompile is enabled.
In the HelloWorld example, we want to set up a default key-value mapping in the state where the key is storageKey and the value is Hello World!. The StateDB allows us to store a key-value mapping of 32-byte hashes. The below code snippet can be copied and pasted to overwrite the default Configure() code.
The event file contains the events that the precompile can emit. This file is located at ./precompile/helloworld/event.go for Subnet-EVM and ./helloworld/event.go for Precompile-EVM. The file begins with a comment about events and how they can be emitted:
In this file you should set your event's gas cost and implement the Get{EventName}EventGasCost function. This function should take the data you want to emit and calculate the gas cost. In this example we defined our event as follow, and plan to emit it in the setGreeting function:
We used arbitrary strings as non-indexed event data, remind that each emitted event is stored on chain, thus charging right amount is critical. We calculated gas cost according to the length of the string to make sure we're charging right amount of gas. If you're sure that you're dealing with a fixed length data, you can use a fixed gas cost for your event. We will show how events can be emitted under the Contract File section.
The contract file contains the functions of the precompile contract that will be called by the EVM. The file is located at ./precompile/helloworld/contract.go for Subnet-EVM and ./helloworld/contract.go for Precompile-EVM. Since we use IAllowList interface there will be auto-generated code for AllowList functions like below:
These will be helpful to use AllowList precompile helper in our functions.
There are also auto-generated Packers and Unpackers for the ABI. These will be used in sayHello and setGreeting functions to comfort the ABI. These functions are auto-generated and will be used in necessary places accordingly. You don't need to worry about how to deal with them, but it's good to know what they are.
Note: There were few changes to precompile packers with Durango. In this example we assumed that the HelloWorld precompile contract has been deployed before Durango. We need to activate this condition only after Durango. If this is a new precompile and never deployed before Durango, you can activate it immediately by removing the if condition.
Each input to a precompile contract function has it's own Unpacker function as follows (if deployed before Durango):
If this is a new precompile that will be deployed after Durango, you can skip strict mode handling and use false:
The ABI is a binary format and the input to the precompile contract function is a byte array. The Unpacker function converts this input to a more easy-to-use format so that we can use it in our function.
Similarly, there is a Packer function for each output of a precompile contract function as follows:
This function converts the output of the function to a byte array that conforms to the ABI and can be returned to the EVM as a result.
The next place to modify is in our sayHello() function. In a previous step, we created the IHelloWorld.sol interface with two functions sayHello() and setGreeting(). We finally get to implement them here. If any contract calls these functions from the interface, the below function gets executed. This function is a simple getter function.
In Configure() we set up a mapping with the key as storageKey and the value as Hello World!. In this function, we will be returning whatever value is at storageKey. The below code snippet can be copied and pasted to overwrite the default setGreeting code.
First, we add a helper function to get the greeting value from the stateDB, this will be helpful when we test our contract. We will use the storageKeyHash to store the value in the Contract's reserved storage in the stateDB.
Now we can modify the sayHello function to return the stored value.
setGreeting() function is a simple setter function. It takes in input and we will set that as the value in the state mapping with the key as storageKey. It also checks if the VM running the precompile is in read-only mode. If it is, it returns an error. At the end of a successful execution, it will emit GreetingChanged event.
There is also a generated AllowList code in that function. This generated code checks if the caller address is eligible to perform this state-changing operation. If not, it returns an error.
Let's add the helper function to set the greeting value in the stateDB, this will be helpful when we test our contract.
The below code snippet can be copied and pasted to overwrite the default setGreeting() code.
Note
Precompile events introduced with Durango. In this example we assumed that the HelloWorld precompile contract has been deployed before Durango.
If this is a new precompile and it will be deployed after Durango, you can activate it immediately by removing the Durango if condition (contract.IsDurangoActivated(accessibleState)).
Setting gas costs for functions is very important and should be done carefully. If the gas costs are set too low, then functions can be abused and can cause DoS attacks. If the gas costs are set too high, then the contract will be too expensive to run.
Subnet-EVM has some predefined gas costs for write and read operations in precompile/contract/utils.go. In order to provide a baseline for gas costs, we have set the following gas costs.
WriteGasCostPerSlot is the cost of one write such as modifying a state storage slot.
ReadGasCostPerSlot is the cost of reading a state storage slot.
This should be in your gas cost estimations based on how many times the precompile function does a read or a write. For example, if the precompile modifies the state slot of its precompile address twice then the gas cost for that function would be 40_000. However, if the precompile does additional operations and requires more computational power, then you should increase the gas costs accordingly.
On top of these gas costs, we also have to account for the gas costs of AllowList gas costs. These are the gas costs of reading and writing permissions for addresses in AllowList. These are defined under Subnet-EVM's precompile/allowlist/allowlist.go.
By default, these are added to the default gas costs of the state-change functions (SetGreeting) of the precompile. Meaning that these functions will cost an additional ReadAllowListGasCost in order to read permissions from the storage. If you don't plan to read permissions from the storage then you can omit these.
Now going back to our /helloworld/contract.go, we can modify our precompile function gas costs. Please search (CTRL F) SET A GAS COST HERE to locate the default gas cost code.
We get and set our greeting with sayHello() and setGreeting() in one slot respectively so we can define the gas costs as follows. We also read permissions from the AllowList in setGreeting() so we keep allowlist.ReadAllowListGasCost.
We should register our precompile package to the Subnet-EVM to be discovered by other packages. Our Module file contains an init() function that registers our precompile. init() is called when the package is imported. We should register our precompile in a common package so that it can be imported by other packages.
For Subnet-EVM we have a precompile registry under /precompile/registry/registry.go. This registry force-imports precompiles from other packages, for example:
The registry itself also force-imported by the `/plugin/evm/vm.go. This ensures that the registry is imported and the precompiles are registered.