Skip to main content

Defining Your Precompile

Now that we have autogenerated the template code required for our precompile, let's actually write the logic for the precompile itself.

Setting Config Key​

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:

{
"helloWorldConfig": {
"blockTimestamp": 0
...
}
}

Setting Contract Address​

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.

// This list is kept just for reference. The actual addresses defined in respective packages of precompiles.
// Note: it is important that none of these addresses conflict with each other or any other precompiles
// in core/vm/contracts.go.
// The first stateful precompiles were added in coreth to support nativeAssetCall and nativeAssetBalance. New stateful precompiles
// originating in coreth will continue at this prefix, so we reserve this range in subnet-evm so that they can be migrated into
// subnet-evm without issue.
// These start at the address: 0x0100000000000000000000000000000000000000 and will increment by 1.
// Optional precompiles implemented in subnet-evm start at 0x0200000000000000000000000000000000000000 and will increment by 1
// from here to reduce the risk of conflicts.
// For forks of subnet-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.
// ContractDeployerAllowListAddress = common.HexToAddress("0x0200000000000000000000000000000000000000")
// ContractNativeMinterAddress = common.HexToAddress("0x0200000000000000000000000000000000000001")
// TxAllowListAddress = common.HexToAddress("0x0200000000000000000000000000000000000002")
// FeeManagerAddress = common.HexToAddress("0x0200000000000000000000000000000000000003")
// RewardManagerAddress = common.HexToAddress("0x0200000000000000000000000000000000000004")
// HelloWorldAddress = common.HexToAddress("0x0300000000000000000000000000000000000000")
// ADD YOUR PRECOMPILE HERE
// {YourPrecompile}Address = common.HexToAddress("0x03000000000000000000000000000000000000??")

Don't forget to update the actual variable ContractAddress in module.go to the address you chose. It should look like this:

// ContractAddress is the defined address of the precompile contract.
// This should be unique across all precompile contracts.
// See params/precompile_modules.go for registered precompile contracts and more information.
var ContractAddress = common.HexToAddress("0x0300000000000000000000000000000000000000")

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.

Adding Custom Code​

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.

Module File​

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.

Configure()​

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.

Config File​

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.

// Config implements the precompileconfig.Config interface and
// adds specific configuration for HelloWorld.
type Config struct {
allowlist.AllowListConfig
precompileconfig.Upgrade
}

Verify()​

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.

// Verify tries to verify Config and returns an error accordingly.
func (c *Config) Verify() error {
// Verify AllowList first
if err := c.AllowListConfig.Verify(); err != nil {
return err
}

// CUSTOM CODE STARTS HERE
// Add your own custom verify code for Config here
// and return an error accordingly
return nil
}

Equal()​

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.

// Equal returns true if [s] is a [*Config] and it has been configured identical to [c].
func (c *Config) Equal(s precompileconfig.Config) bool {
// typecast before comparison
other, ok := (s).(*Config)
if !ok {
return false
}
// CUSTOM CODE STARTS HERE
// modify this boolean accordingly with your custom Config, to check if [other] and the current [c] are equal
// if Config contains only Upgrade and AllowListConfig you can skip modifying it.
equals := c.Upgrade.Equal(&other.Upgrade) && c.AllowListConfig.Equal(&other.AllowListConfig)
return equals
}

We can leave this function as is since we check Upgrade and AllowListConfig for equality which are the only fields that Config struct has.

Modify Configure()​

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.

const defaultGreeting = "Hello World!"

// Configure configures [state] with the given [cfg] precompileconfig.
// This function is called by the EVM once per precompile contract activation.
// You can use this function to set up your precompile contract's initial state,
// by using the [cfg] config and [state] stateDB.
func (*configurator) Configure(chainConfig contract.ChainConfig, cfg precompileconfig.Config, state contract.StateDB, _ contract.BlockContext) error {
config, ok := cfg.(*Config)
if !ok {
return fmt.Errorf("incorrect config %T: %v", config, config)
}
// CUSTOM CODE STARTS HERE

// This will be called in the first block where HelloWorld stateful precompile is enabled.
// 1) If BlockTimestamp is nil, this will not be called
// 2) If BlockTimestamp is 0, this will be called while setting up the genesis block
// 3) If BlockTimestamp is 1000, this will be called while processing the first block
// whose timestamp is >= 1000
//
// Set the initial value under [common.BytesToHash([]byte("storageKey")] to "Hello World!"
StoreGreeting(state, defaultGreeting)
// AllowList is activated for this precompile. Configuring allowlist addresses here.
return config.AllowListConfig.Configure(state, ContractAddress)
}

Event File​

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:

/* NOTE: Events can only be emitted in state-changing functions. So you cannot use events in read-only (view) functions.
Events are generally emitted at the end of a state-changing function with AddLog method of the StateDB. The AddLog method takes 4 arguments:
1. Address of the contract that emitted the event.
2. Topic hashes of the event.
3. Encoded non-indexed data of the event.
4. Block number at which the event was emitted.
The first argument is the address of the contract that emitted the event.
Topics can be at most 4 elements, the first topic is the hash of the event signature and the rest are the indexed event arguments. There can be at most 3 indexed arguments.
Topics cannot be fully unpacked into their original values since they're 32-bytes hashes.
The non-indexed arguments are encoded using the ABI encoding scheme. The non-indexed arguments can be unpacked into their original values.
Before packing the event, you need to calculate the gas cost of the event. The gas cost of an event is the base gas cost + the gas cost of the topics + the gas cost of the non-indexed data.
See Get{EvetName}EventGasCost functions for more details.
You can use the following code to emit an event in your state-changing precompile functions (generated packer might be different):
topics, data, err := PackMyEvent(
topic1,
topic2,
data1,
data2,
)
if err != nil {
return nil, remainingGas, err
}
accessibleState.GetStateDB().AddLog(
ContractAddress,
topics,
data,
accessibleState.GetBlockContext().Number().Uint64(),
)

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:

  event GreetingChanged(address indexed sender, string oldGreeting, string newGreeting);

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.

Contract File​

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:

// GetHelloWorldAllowListStatus returns the role of [address] for the HelloWorld list.
func GetHelloWorldAllowListStatus(stateDB contract.StateDB, address common.Address) allowlist.Role {
return allowlist.GetAllowListStatus(stateDB, ContractAddress, address)
}

// SetHelloWorldAllowListStatus sets the permissions of [address] to [role] for the
// HelloWorld list. Assumes [role] has already been verified as valid.
// This stores the [role] in the contract storage with address [ContractAddress]
// and [address] hash. It means that any reusage of the [address] key for different value
// conflicts with the same slot [role] is stored.
// Precompile implementations must use a different key than [address] for their storage.
func SetHelloWorldAllowListStatus(stateDB contract.StateDB, address common.Address, role allowlist.Role) {
allowlist.SetAllowListRole(stateDB, ContractAddress, address, role)
}

These will be helpful to use AllowList precompile helper in our functions.

Packers and Unpackers​

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):

// UnpackSetGreetingInput attempts to unpack [input] into the string type argument
// assumes that [input] does not include selector (omits first 4 func signature bytes)
// if [useStrictMode] is true, it will return an error if the length of [input] is not [common.HashLength]
func UnpackSetGreetingInput(input []byte, useStrictMode bool) (string, error) {
// Initially we had this check to ensure that the input was the correct length.
// However solidity does not always pack the input to the correct length, and allows
// for extra padding bytes to be added to the end of the input. Therefore, we have removed
// this check with the Durango. We still need to keep this check for backwards compatibility.
if useStrictMode && len(input) > common.HashLength {
return "", ErrInputExceedsLimit
}
res, err := HelloWorldABI.UnpackInput("setGreeting", input, useStrictMode)
if err != nil {
return "", err
}
unpacked := *abi.ConvertType(res[0], new(string)).(*string)
return unpacked, nil
}

If this is a new precompile that will be deployed after Durango, you can skip strict mode handling and use false:

func UnpackSetGreetingInput(input []byte) (string, error) {
res, err := HelloWorldABI.UnpackInput("setGreeting", input, false)
if err != nil {
return "", err
}
unpacked := *abi.ConvertType(res[0], new(string)).(*string)
return unpacked, nil
}

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:

// PackSayHelloOutput attempts to pack given result of type string
// to conform the ABI outputs.
func PackSayHelloOutput(result string) ([]byte, error) {
return HelloWorldABI.PackOutput("sayHello", result)
}

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.

Modify sayHello()​

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.

var (
// storageKeyHash is the hash of the storage key "storageKey" in the contract storage.
// This is used to store the value of the greeting in the contract storage.
// It is important to use a unique key here to avoid conflicts with other storage keys
// like addresses, AllowList, etc.
storageKeyHash = common.BytesToHash([]byte("storageKey"))
)
// GetGreeting returns the value of the storage key "storageKey" in the contract storage,
// with leading zeroes trimmed.
// This function is mostly used for tests.
func GetGreeting(stateDB contract.StateDB) string {
// Get the value set at recipient
value := stateDB.GetState(ContractAddress, storageKeyHash)
return string(common.TrimLeftZeroes(value.Bytes()))
}

Now we can modify the sayHello function to return the stored value.

func sayHello(accessibleState contract.AccessibleState, caller common.Address, addr common.Address, input []byte, suppliedGas uint64, readOnly bool) (ret []byte, remainingGas uint64, err error) {
if remainingGas, err = contract.DeductGas(suppliedGas, SayHelloGasCost); err != nil {
return nil, 0, err
}
// CUSTOM CODE STARTS HERE

// Get the current state
currentState := accessibleState.GetStateDB()
// Get the value set at recipient
value := GetGreeting(currentState)
packedOutput, err := PackSayHelloOutput(value)
if err != nil {
return nil, remainingGas, err
}

// Return the packed output and the remaining gas
return packedOutput, remainingGas, nil
}

Modify setGreeting()​

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.

// StoreGreeting sets the value of the storage key "storageKey" in the contract storage.
func StoreGreeting(stateDB contract.StateDB, input string) {
inputPadded := common.LeftPadBytes([]byte(input), common.HashLength)
inputHash := common.BytesToHash(inputPadded)

stateDB.SetState(ContractAddress, storageKeyHash, inputHash)
}

The below code snippet can be copied and pasted to overwrite the default setGreeting() code.

func setGreeting(accessibleState contract.AccessibleState, caller common.Address, addr common.Address, input []byte, suppliedGas uint64, readOnly bool) (ret []byte, remainingGas uint64, err error) {
if remainingGas, err = contract.DeductGas(suppliedGas, SetGreetingGasCost); err != nil {
return nil, 0, err
}
if readOnly {
return nil, remainingGas, vmerrs.ErrWriteProtection
}
// do not use strict mode after Durango
useStrictMode := !contract.IsDurangoActivated(accessibleState)
// attempts to unpack [input] into the arguments to the SetGreetingInput.
// Assumes that [input] does not include selector
// You can use unpacked [inputStruct] variable in your code
inputStruct, err := UnpackSetGreetingInput(input, useStrictMode)
if err != nil {
return nil, remainingGas, err
}
// Allow list is enabled and SetGreeting is a state-changer function.
// This part of the code restricts the function to be called only by enabled/admin addresses in the allow list.
// You can modify/delete this code if you don't want this function to be restricted by the allow list.
stateDB := accessibleState.GetStateDB()
// Verify that the caller is in the allow list and therefore has the right to call this function.
callerStatus := allowlist.GetAllowListStatus(stateDB, ContractAddress, caller)
if !callerStatus.IsEnabled() {
return nil, remainingGas, fmt.Errorf("%w: %s", ErrCannotSetGreeting, caller)
}
// allow list code ends here.

// CUSTOM CODE STARTS HERE
// With Durango, you can emit an event in your state-changing precompile functions.
// Note: If you have been using the precompile before Durango, you should activate it only after Durango.
// Activating this code before Durango will result in a consensus failure.
// If this is a new precompile and never deployed before Durango, you can activate it immediately by removing
// the if condition.
// This example assumes that the HelloWorld precompile contract has been deployed before Durango.
if contract.IsDurangoActivated(accessibleState) {
// We will first read the old greeting. So we should charge the gas for reading the storage.
if remainingGas, err = contract.DeductGas(remainingGas, contract.ReadGasCostPerSlot); err != nil {
return nil, 0, err
}
oldGreeting := GetGreeting(stateDB)

eventData := GreetingChangedEventData{
OldGreeting: oldGreeting,
NewGreeting: inputStruct,
}
topics, data, err := PackGreetingChangedEvent(caller, eventData)
if err != nil {
return nil, remainingGas, err
}
// Charge the gas for emitting the event.
eventGasCost := GetGreetingChangedEventGasCost(eventData)
if remainingGas, err = contract.DeductGas(remainingGas, eventGasCost); err != nil {
return nil, 0, err
}

// Emit the event
stateDB.AddLog(
ContractAddress,
topics,
data,
accessibleState.GetBlockContext().Number().Uint64(),
)
}

// setGreeting is the execution function
// "SetGreeting(name string)" and sets the storageKey
// in the string returned by hello world
StoreGreeting(stateDB, inputStruct)

// This function does not return an output, leave this one as is
packedOutput := []byte{}

// Return the packed output and the remaining gas
return packedOutput, remainingGas, nil
}

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​

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.

// Gas costs for stateful precompiles
const (
WriteGasCostPerSlot = 20_000
ReadGasCostPerSlot = 5_000
)

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.

SayHelloGasCost    uint64 = 0                                  // SET A GAS COST HERE
SetGreetingGasCost uint64 = 0 + allowlist.ReadAllowListGasCost // SET A GAS COST HERE

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.

SayHelloGasCost    uint64 = contract.ReadGasCostPerSlot
SetGreetingGasCost uint64 = contract.WriteGasCostPerSlot + allowlist.ReadAllowListGasCost

Registering Your Precompile​

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:

// Force imports of each precompile to ensure each precompile's init function runs and registers itself
// with the registry.
import (
_ "github.com/ava-labs/subnet-evm/precompile/contracts/deployerallowlist"

_ "github.com/ava-labs/subnet-evm/precompile/contracts/nativeminter"

_ "github.com/ava-labs/subnet-evm/precompile/contracts/txallowlist"

_ "github.com/ava-labs/subnet-evm/precompile/contracts/feemanager"

_ "github.com/ava-labs/subnet-evm/precompile/contracts/rewardmanager"

_ "github.com/ava-labs/subnet-evm/precompile/contracts/helloworld"
// ADD YOUR PRECOMPILE HERE
// _ "github.com/ava-labs/subnet-evm/precompile/contracts/yourprecompile"
)

The registry itself also force-imported by the `/plugin/evm/vm.go. This ensures that the registry is imported and the precompiles are registered.

Was this page helpful?