Use Subnet-EVM Stateful Precompiles on Avalanche to Rapidly Deploy Your High Performance dApp

Avax Developers
14 min readAug 3, 2023

--

Introduction

Avalanche is a platform that powers the execution transactions for decentralized applications. It’s comprised of subnetworks called Subnets consisting of custom virtual machines (VMs) and complex validator rulesets. One such VM is Subnet-EVM. This technical deep-dive reviews how Subnet-EVM can customize the VM’s deployment via Stateful Precompiles, allowing for Subnets to adapt to all of your dApp’s needs.

Subnet-EVM

Avalanche is a network composed of many blockchains. Each blockchain is an instance of a VM, much like an object is an instance of a class in an object-oriented language. That is, the VM defines the behavior of the blockchain.

Subnet-EVM is a simplified version of Coreth, the VM of the Contract Chain (C-Chain). It implements the Ethereum Virtual Machine (EVM) and supports Solidity smart contracts, as well as most other Ethereum client functionality, and is compatible with many of EVM toolchains and wallets.

EVM Precompile

Originally defined in Appendix E of the Ethereum Yellowpaper, precompiled contracts are written in low-level code and are responsible for implementing operations or computations that are used by smart contracts. These precompiled contracts provide efficient and optimized implementations of cryptographic operations. They’re included in the EVM protocol to enable smart contracts to perform complex computations, without the need for developers to write custom code.

PrecompiledContract

All Precompiles must implement the PrecompiledContract interface

// PrecompiledContract is the basic interface for native Go contracts. 
// The implementation requires a deterministic gas count based on the
// input size of the Run method of the contract.
type PrecompiledContract interface {
RequiredGas(input []byte) uint64
Run(input []byte) ([]byte, error)
}

PrecompileContract has 2 functions which need to be implemented. RequiredGas calculates the contract gas use and Run runs the precompiled contract.

NOTE the PrecompiledContract is different than the StatefulPrecompiledContract listed below.

These precompile addresses start from 0x0000000000000000000000000000000000000001 and increment by 1.

EVM Precompiled contracts include the following. The numbering is the address of the contract:

  1. Recovery of ECDSA signature
  2. Hash function SHA256
  3. Hash function RIPEMD160
  4. Identity
  5. Modular exponentiation (EIP 198)
  6. Addition on elliptic curve alt_bn128 (EIP 196)
  7. Scalar multiplication on elliptic curve alt_bn128 (EIP 196)
  8. Checking a pairing equation on curve alt_bn128 (EIP 197)
  9. BLAKE2b hash function (EIP 152)

Precompiles are shortcuts to execute a function implemented by the EVM itself, rather than an actual contract. They’re useful when the desired functionality would be cumbersome and awkward to implement in Solidity, such as cryptographic functions. Here’s the RIPEMD160 cryptographic function implemented as a precompile. Note that it implements the RequiredGas and Run functions per the PrecompiledContract interface.

RIPEMD160 Implementation

// RIPEMD160 implemented as a native contract.
type ripemd160hash struct{}

// RequiredGas returns the gas required to execute the pre-compiled contract.
//
// This method does not require any overflow checking as the input size
// gas costs
// required for anything significant is so high it's impossible to pay for.
func (c *ripemd160hash) RequiredGas(input []byte) uint64 {
return uint64(len(input)+31)/32*params.Ripemd160PerWordGas + params.Ripemd160BaseGas
}

func (c *ripemd160hash) Run(input []byte) ([]byte, error) {
ripemd := ripemd160.New()
ripemd.Write(input)
return common.LeftPadBytes(ripemd.Sum(nil), 32), nil
}

Developers can leverage precompiles by invoking the keywords:

return ripemd160hash(input)

Behind the scenes, precompiles are triggered through CALL opcodes utilizing specific addresses. These CALL opcodes, including CALL, STATICCALL, DELEGATECALL, and CALLCODE, have the ability to invoke a precompile. Typically, these opcodes are employed to call a smart contract, with the input representing encoded parameters for the smart contract call. However, when a precompile is involved, the input is passed to the precompile, allowing it to execute the operation and deduct the necessary gas from the transaction's execution context.

Here are the original 9 cryptographic functions implemented as precompiles:

  1. Recovery of ECDSA signature
  2. Hash function SHA256
  3. Hash function RIPEMD160
  4. Identity
  5. Modular exponentiation
  6. Addition on elliptic curve alt_bn128
  7. Scalar multiplication on elliptic curve alt_bn128
  8. Checking a pairing equation on curve alt_bn128
  9. BLAKE2b hash function

We can see these precompile mappings from address to function here in Coreth:

// PrecompiledContractsBanff contains the default set of pre-compiled Ethereum
// contracts used in the Banff release.
var PrecompiledContractsBanff = map[common.Address]precompile.StatefulPrecompiledContract{
common.BytesToAddress([]byte{1}): newWrappedPrecompiledContract(&ecrecover{}),
common.BytesToAddress([]byte{2}): newWrappedPrecompiledContract(&sha256hash{}),
common.BytesToAddress([]byte{3}): newWrappedPrecompiledContract(&ripemd160hash{}),
common.BytesToAddress([]byte{4}): newWrappedPrecompiledContract(&dataCopy{}),
common.BytesToAddress([]byte{5}): newWrappedPrecompiledContract(&bigModExp{eip2565: true}),
common.BytesToAddress([]byte{6}): newWrappedPrecompiledContract(&bn256AddIstanbul{}),
common.BytesToAddress([]byte{7}): newWrappedPrecompiledContract(&bn256ScalarMulIstanbul{}),
common.BytesToAddress([]byte{8}): newWrappedPrecompiledContract(&bn256PairingIstanbul{}),
common.BytesToAddress([]byte{9}): newWrappedPrecompiledContract(&blake2F{}),

By utilizing these precompiled contracts, smart contracts can perform cryptographic computations more efficiently and with reduced gas costs. Developers can call these precompiled contracts directly from their own smart contracts, saving computational resources and simplifying the development process.

Precompiled contracts are an integral part of any EVM ecosystem, providing optimized implementations of cryptographic operations for smart contracts.

Stateful Precompile

Avalanche enables precompiles to have greater functionality by adding state access. There are 5 stateful precompiles which can be enabled via ChainConfig in genesis or as an upgrade.

  • Restricting Smart Contract Deployers
  • Restricting Who Can Submit Transactions
  • Minting Native Coins
  • Configuring Dynamic Fees
  • Changing Fee Reward Mechanisms

AllowList

Permissions can be enforced by a stateful precompile on an address via the AllowList interface. The AllowList is not a contract itself, but a helper structure to provide a control mechanism for wrapping contracts. It provides an AllowListConfig to the precompile so that it can take an initial configuration from genesis/upgrade.

Each Precompile, which is using AllowList , has 3 permission levels. Admin lets you create and remove other Admins in addition to using the precompile. Enabled lets you use the precompile. None prevents you from using the precompile.

pragma solidity ^0.8.0;

interface IAllowList {
// Set [addr] to have the admin role over the precompile
function setAdmin(address addr) external;

// Set [addr] to be enabled on the precompile contract.
function setEnabled(address addr) external;

// Set [addr] to have no role the precompile contract.
function setNone(address addr) external;

// Read the status of [addr].
function readAllowList(address addr) external view returns (uint256 role);
}

ContractDeployerAllowList

AllowList adds adminAddresses and enabledAddresses fields to precompile contract configurations. For instance, configure the contract deployer precompile contract configuration by adding the following JSON to your genesis or upgrade JSON.

ContractDeployerAllowList enables restricting which accounts can deploy a smart contract to your instance of Subnet EVM. Configure by adding the following JSON to your genesis or upgrade JSON.

"contractDeployerAllowListConfig": {
"adminAddresses": [
"0x41B3E74d0dC7c5f573c8cFB007C45a494B1B10F7"
],
"enabledAddresses": [
"0x7326165202aed51E8dd3750Ff59a048F73579960"
],
"blockTimestamp": 0
},

The Stateful Precompile contract, which powers the ContractDeployerAllowList, adheres to the AllowList Solidity interface at 0x0200000000000000000000000000000000000000 (you can load this interface and interact directly in Remix).

ContractNativeMinter

ContractNativeMinter enables restricting, which accounts can mint native(gas) coins. Configure this by adding the following JSON to your genesis or upgrade JSON.

"contractNativeMinterConfig": {
"adminAddresses": [
"0x717b7948AA264DeCf4D780aa6914482e5F46Da3e"
],
"enabledAddresses": [
"0x3287591FC6C6Ef2E7AcF9293f458ECAA6Ed9bc63"
],
"blockTimestamp": 0
},

The Stateful Precompile contract powering the ContractNativeMinter adheres to the following Solidity interface at 0x0200000000000000000000000000000000000001 (you can load this interface and interact directly in Remix):

pragma solidity ^0.8.0;
import "./IAllowList.sol";

interface INativeMinter is IAllowList {
// Mint [amount] number of native coins and send to [addr]
function mintNativeCoin(address addr, uint256 amount) external;
}

TxAllowList

TxAllowList enables restricting which accounts can submit a transaction to your instance of Subnet EVM. Configure this by adding the following JSON to your genesis or upgrade JSON.

"txAllowListConfig": {
"adminAddresses": [
"0xB79FFB4eCaAb0135062c86F24dd2ff75112b646C"
],
"enabledAddresses": [
"0xCa0F57D295bbcE554DA2c07b005b7d6565a58fCE"
],
"blockTimestamp": 0
},

The Stateful Precompile contract powering the TxAllowList adheres to the AllowList Solidity interface at 0x0200000000000000000000000000000000000002 (you can load this interface and interact directly in Remix):

FeeManager

FeeManager enables configuring the parameters of the dynamic fee algorithm. Configure this by adding the following JSON to your genesis or upgrade JSON.

"feeManagerConfig": {
"adminAddresses": [
"0x99ef71E554E54a7135d032ab30FC476DC55f9315"
],
"enabledAddresses": [
"0x7B137BB4704b977a23Cc8055C2C87674B730aa0a"
],
"blockTimestamp": 0
},

The Stateful Precompile contract powering the FeeConfigManager adheres to the following Solidity interface at 0x0200000000000000000000000000000000000003 (you can load this interface and interact directly in Remix).

pragma solidity ^0.8.0;
import "./IAllowList.sol";

interface IFeeManager is IAllowList {
// Set fee config fields to contract storage
function setFeeConfig(
uint256 gasLimit,
uint256 targetBlockRate,
uint256 minBaseFee,
uint256 targetGas,
uint256 baseFeeChangeDenominator,
uint256 minBlockGasCost,
uint256 maxBlockGasCost,
uint256 blockGasCostStep
) external;

// Get fee config from the contract storage
function getFeeConfig()
external
view
returns (
uint256 gasLimit,
uint256 targetBlockRate,
uint256 minBaseFee,
uint256 targetGas,
uint256 baseFeeChangeDenominator,
uint256 minBlockGasCost,
uint256 maxBlockGasCost,
uint256 blockGasCostStep
);

// Get the last block number changed the fee config from the contract storage
function getFeeConfigLastChangedAt() external view returns (uint256 blockNumber);
}`

RewardManager

RewardManager enables configuring the fee reward mechanism, including burning fees, sending fees to a predefined address, or enabling fees to be collected by block producers. Configure this by adding the following JSON to your genesis or upgrade JSON.

"rewardManagerConfig": {
"adminAddresses": [
"0x99ef71E554E54a7135d032ab30FC476DC55f9315"
],
"enabledAddresses": [
"0x7B137BB4704b977a23Cc8055C2C87674B730aa0a"
],
"blockTimestamp": 0,
"initialRewardConfig": {
"allowFeeRecipients": true,
"rewardAddress": "0x0000000000000000000000000000000000000000"
}
},

The Stateful Precompile contract powering the RewardManager adheres to the following Solidity interface at 0x0200000000000000000000000000000000000004 (you can load this interface and interact directly in Remix).

pragma solidity ^0.8.0;
import "./IAllowList.sol";

interface IRewardManager is IAllowList {
// setRewardAddress sets the reward address to the given address
function setRewardAddress(address addr) external;

// allowFeeRecipients allows block builders to claim fees
function allowFeeRecipients() external;

// disableRewards disables block rewards and starts burning fees
function disableRewards() external;

// currentRewardAddress returns the current reward address
function currentRewardAddress() external view returns (address rewardAddress);

// areFeeRecipientsAllowed returns true if fee recipients are allowed
function areFeeRecipientsAllowed() external view returns (bool isAllowed);
}

CALL

The CALL opcode (CALL, STATICCALL, DELEGATECALL, and CALLCODE) allows us to invoke this precompile.

The function signature of CALL in the EVM is as follows:

Call(
caller ContractRef,
addr common.Address,
input []byte,
gas uint64,
value *big.Int,
)(ret []byte, leftOverGas uint64, err error)

When a precompile is called, the EVM checks if the input address is a precompile address. If the input address is a precompile address, it executes the precompile. Otherwise, it loads the smart contract at the input address and runs it on the EVM interpreter with the specified input data.

Calling a Precompile from Solidity

Remix comes with a simple contract called Storage.sol, which lets you write/read a number to/from the blockchain. Open Storage.sol and add the following hashNumber function.

/**
* @dev Return hash
* @return hash
*/
function hashNumber() public view returns (bytes32 hash) {
(bool ok, bytes memory out) = address(2).staticcall(abi.encode(number));
require(ok);
hash = abi.decode(out, (bytes32));
return hash;
}

hashNumber returns the sha256 hash of number by calling the sha256 precompile located at address(2) or 0x000000000000000000000000000000000002.

The complete Storage.sol contract should look like this:

// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.7.0 <0.9.0;

/**
* @title Storage
* @dev Store & retrieve value in a variable
* @custom:dev-run-script ./scripts/deploy_with_ethers.ts
*/
contract Storage {

uint256 number;

/**
* @dev Store value in variable
* @param num value to store
*/
function store(uint256 num) public {
number = num;
}

/**
* @dev Return value
* @return value of 'number'
*/
function retrieve() public view returns (uint256){
return number;
}

/**
* @dev Return hash
* @return hash
*/
function hashNumber() public view returns (bytes32 hash) {
(bool ok, bytes memory out) = address(2).staticcall(abi.encode(number));
require(ok);
hash = abi.decode(out, (bytes32));
return hash;
}
}

Alternatively, you can interface with the sha256 interface directly. First, create a new file called Isha256.sol and paste in the following contents and save the file.

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.0;

interface ISHA256 {

// Computes the SHA256 hash of value
function run(string memory value) external view returns (bytes32 hash);
}

Switch to the “Deploy & Run Transactions” view in the left sidebar. Select the Injected Provider as the environment, let Core connect to Remix, and paste this precompile address below in the “At Address” field: 0x0000000000000000000000000000000000000002

Next, click the blue ”At Address” button. A new compiled contract appears below. Click the small arrow next to SHA256 and call the function with any string.

You can see the return value of the hash function below the input field.

Avalanche Stateful Precompile

A stateful precompile builds on a precompile and adds state access. Stateful precompiles are not available in the default EVM, and are specific to Avalanche EVMs such as Coreth and Subnet-EVM.

A stateful precompile follows this interface:

// StatefulPrecompiledContract is the interface for executing a 
// precompiled contract
type StatefulPrecompiledContract interface {
// Run executes the precompiled contract.
Run(accessibleState PrecompileAccessibleState,
caller common.Address,
addr common.Address,
input []byte,
suppliedGas uint64,
readOnly bool)
(ret []byte, remainingGas uint64, err error)
}

NOTE the StatefulPrecompiledContract is different than the PrecompiledContract listed above.

Precompile-EVM

Precompile-EVM is a repository for registering precompiles to Subnet-EVM without forking the Subnet-EVM codebase. Subnet-EVM supports registering external precompiles through precompile/modules package. By importing Subnet-EVM as a library, you can register your precompiles to Subnet-EVM and build it together with Subnet-EVM.

HelloWorld is an example stateful precompile that ships with Precompile-EVM. Here we’re going to configure and deploy that precompile. Next, create a new precompile using code generation scripts that ship with Precompile-EVM. The example precompile will have the same functionality as HelloWorld and will serve primarily as example steps for using the code generation scripts.

Clone the Repo

1. Clone the Precompile-EVM GitHub repo to your local machine.

git clone <https://github.com/ava-labs/precompile-evm.git>
cd precompile-evm/

Checkout the hello-world-example Branch

2. Once you have the Precompile-EVM repo, check out the appropriate branch.

git checkout hello-world-example

Install Dependencies

3. Next, you have to cd contracts/ and run npm install to get the dependencies. You’ll need the latest version of NodeJS installed which comes bundled with npm.

cd contracts/
npm install
cd ../

Config the HelloWorld Precompile

The configuration file for the HelloWorld precompile is located at tests/precompile/genesis/hello_world.json. In addition to the HelloWorld precompile, you can add config for any existing precompiles. You can also config the Subnet’s tokenomics, which include initial coin allocation. By default, it’s configured to airdrop 1M of your Subnet’s gas token to 0x8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC, which is the address derived from 0x56289e99c94b6912bfc12adc093c9b51124f0dc54ac7a766b2bc5ccf558d8027 which is a private key that we share publicly in our docs and use for airdropping tokens onto new networks.

You can also add any other addresses and balances which you want to airdop on your new subnet.

"alloc": {
"8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC": {
"balance": "0x52B7D2DCC80CD2E4000000"
}
},

Additionally, you can config additional precompiles. Let’s update hello_world.json to enable the 0x8db from above to be able to mint additional gas tokens.

"contractNativeMinterConfig": {
"adminAddresses": [
"0x8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC"
],
"blockTimestamp": 0
}

You also want to enable and configure the HelloWorld precompile.

"helloWorldConfig": {
"blockTimestamp": 0,
"adminAddresses": [
"0x8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC"
]
},

Start a Local Network

Using the Avalanche Network Runner, fire up a server

avalanche-network-runner server

In a different tab, start a 5 node network

avalanche-network-runner control start \\
--number-of-nodes=5 \\
--blockchain-specs '[{"vm_name": "subnetevm", "genesis": "./tests/precompile/genesis/hello_world.json"}]'

After the network is finished building, you should see RPC URIs in the logs

[07-25|17:02:18.113] INFO server/network.go:649 [blockchain RPC for "srEXiWaHuhNyGwPUi444Tu47ZEDwxTWrbQiuD7FmgSAQ6X7Dy"] "<http://127.0.0.1:9650/ext/bc/2RQiKekQrhAR7maFz1PSF9MCs6kH3SyMAMLWVP4zossXkq6SDJ>"

Connect a Wallet to the Subnet

In MetaMask, on the network dropdown, select “Add network”

On the next page, select “Add a network manually”

On the last page, enter the RPC information. Note you want to add /rpc at the end of the RPC uri from the Avalanche Network Runner logs.

Now, you should be successfully able to join

Import Funded Account

In hello_world.json , we allocated 1m gas tokens to the 0x8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC address. To import the key, select the dropdown menu from the first account and select “Import amount”

Next, paste in the private key 0x56289e99c94b6912bfc12adc093c9b51124f0dc54ac7a766b2bc5ccf558d8027

Once the account is imported, it will have a 1M token balance.

Remix

Now, connect Remix to the wallet to interact with the precompiles on the Subnet. Head to Remix and under the “Deploy and Run Transactions” tab along the left select the “Environment” drop-down menu, select Injected Provider.

A MetaMask popup will ask which accounts to connect to Remix. Select both addresses so that either can interact with the blockchain.

You can now mint additional HW tokens. As mentioned above, you can interface with the ContractNativeMinter precompile at 0x0200000000000000000000000000000000000001. In the “File Explorer” tab, open INativeMinter.sol or create a new file and paste into the INativeMinter interface.

Once open, compile the interface in the “Deploy and Run Transactions” tab and paste the address for the INativeMinter interface, 0x0200000000000000000000000000000000000001 into the “Add Address” input field and click “Add Address.”

Remix will show a form with input fields, that match the Solidity interface. First, paste the address into the readAllowList input field and note it returns “role 2” which signifies that address has admin permissions for the precompile.

Enter the address of the account you want to mint more tokens to, in this case, 0x8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC, and the amount of tokens to mint

We’ve minted 1234 new tokens and the balance shows in Metamask.

HelloWorld Precompile

Now, we can interface with the HelloWorld precompile. Head over to Remix and create a new .sol file. Paste the IHelloWorld.sol interface

pragma solidity >=0.8.0;
import "@avalabs/subnet-evm-contracts/contracts/interfaces/IAllowList.sol";

interface IHelloWorld is IAllowList {
// sayHello returns the stored greeting string
function sayHello() external view returns (string calldata result);

// setGreeting stores the greeting string
function setGreeting(string calldata response) external;
}

The HelloWorld precompile is located in the helloworld/ directory. In helloworld/module.go , look for the ContractAddress variable to see at what hex address the precompile is deployed.

var ContractAddress = common.HexToAddress("0x0300000000000000000000000000000000000000")

Also, look in plugin/main.go and confirm that helloworld is being imported.

_ "github.com/ava-labs/precompile-evm/helloworld"

Open IHelloWorld.sol in Remix and compile it.

In the “Deploy and Run Transactions” tab, enter 0x0300000000000000000000000000000000000000 into the “At Address” input field to bring up a GUI for interacting with HelloWorld. Next, click sayHello and you will see the string “Hello World.” This is being read from the stateful precompile!

Now, enter “hola mundo” into the setGreeting field and submit that transaction. Once it’s successful, click sayHello again and you will see the string “hola mundo.” This has successfully wrote and read to the blockchain via the stateful precompile! 🏆

Conclusion

EVM Precompiles provides an API for developers to create optimized and efficient implementations of functionality in Go, as opposed to implementing the functionality in Solidity. Avalanche Stateful Precompiles go one step further by providing access to State.

We introduced precompiles and their history going all the way back to the Ethereum Yellow paper. We also introduced Stateful Precompiles and showed how to interface with the 5 stateful precompiles which come bundled with Subnet EVM. Using those learnings, we then showed you how to clone Precompile EVM and interact with the HelloWorld stateful precompile.

--

--

Avax Developers

The go-to resource and community hub for developers building on Avalanche