The Hackett Group Announces Strategic Acquisition of Leading Gen AI Development Firm LeewayHertz
Select Page

How to Create a Virtual Machine on Avalanche

avalanche-virtual-machine

How to create a virtual machine on Avalanche?

This article will show you how to create a simple Avalanche virtual machine called TimestampVM. Each block of the TimestampVM’s blockchain contains a 32-byte payload and an increasing timestamp at the block creation time. This server can be used to prove that a block of data existed at the time it was created.

Step 1: Prerequisites

1. Interfaces that every VM must implement

  • block.ChainVM– To reach a consensus on linear blockchains, Avalanche uses the Snowman consensus engine. To be compatible with Snowman, a VM must implement the block.ChainVM interface.
type ChainVM interface {
common.VM
Getter
Parser

BuildBlock() (snowman.Block, error)
LastAccepted() (ids.ID, error)
}

// Getter defines the functionality for fetching a block by its ID.
type Getter interface {
// Attempt to load a block.
//
// If the block does not exist, an error should be returned.
//
GetBlock(ids.ID) (snowman.Block, error)
}

// Parser defines the functionality for fetching a block by its bytes.
type Parser interface {
// Attempt to create a block from a stream of bytes.
//
// The block should be represented by the full byte array, without extra
// bytes.
ParseBlock([]byte) (snowman.Block, error)
}
  • common.VM – This is a type that every VM must implement.
type VM interface {
// Contains handlers for VM-to-VM specific messages
AppHandler

// Returns nil if the VM is healthy.
// Periodically called and reported via the node's Health API.
health.Checkable

// Connector represents a handler that is called on connection connect/disconnect
validators.Connector

// Initialize this VM.
// [ctx]: Metadata about this VM.
// [ctx.networkID]: The ID of the network this VM's chain is running on.
// [ctx.chainID]: The unique ID of the chain this VM is running on.
// [ctx.Log]: Used to log messages
// [ctx.NodeID]: The unique staker ID of this node.
// [ctx.Lock]: A Read/Write lock shared by this VM and the consensus
// engine that manages this VM. The write lock is held
// whenever code in the consensus engine calls the VM.
// [dbManager]: The manager of the database this VM will persist data to.
// [genesisBytes]: The byte-encoding of the genesis information of this
// VM. The VM uses it to initialize its state. For
// example, if this VM were an account-based payments
// system, `genesisBytes` would probably contain a genesis
// transaction that gives coins to some accounts, and this
// transaction would be in the genesis block.
// [toEngine]: The channel used to send messages to the consensus engine.
// [fxs]: Feature extensions that attach to this VM.
Initialize(
ctx *snow.Context,
dbManager manager.Manager,
genesisBytes []byte,
upgradeBytes []byte,
configBytes []byte,
toEngine chan<- Message,
fxs []*Fx,
appSender AppSender,
) error

// Bootstrapping is called when the node is starting to bootstrap this chain.
Bootstrapping() error

// Bootstrapped is called when the node is done bootstrapping this chain.
Bootstrapped() error

// Shutdown is called when the node is shutting down.
Shutdown() error

// Version returns the version of the VM this node is running.
Version() (string, error)

// Creates the HTTP handlers for custom VM network calls.
//
// This exposes handlers that the outside world can use to communicate with
// a static reference to the VM. Each handler has the path:
// [Address of node]/ext/VM/[VM ID]/[extension]
//
// Returns a mapping from [extension]s to HTTP handlers.
//
// Each extension can specify how locking is managed for convenience.
//
// For example, it might make sense to have an extension for creating
// genesis bytes this VM can interpret.
CreateStaticHandlers() (map[string]*HTTPHandler, error)

// Creates the HTTP handlers for custom chain network calls.
//
// This exposes handlers that the outside world can use to communicate with
// the chain. Each handler has the path:
// [Address of node]/ext/bc/[chain ID]/[extension]
//
// Returns a mapping from [extension]s to HTTP handlers.
//
// Each extension can specify how locking is managed for convenience.
//
// For example, if this VM implements an account-based payments system,
// it have an extension called `accounts`, where clients could get
// information about their accounts.
CreateHandlers() (map[string]*HTTPHandler, error)
}
  • snowman.Block – The snowman.Block interface defines the functionality a block must implement to be a block in a linear Snowman chain.
type Block interface {
choices.Decidable

// Parent returns the ID of this block's parent.
Parent() ids.ID

// Verify that the state transition this block would make if accepted is
// valid. If the state transition is invalid, a non-nil error should be
// returned.
//
// It is guaranteed that the Parent has been successfully verified.
Verify() error

// Bytes returns the binary representation of this block.
//
// This is used for sending blocks to peers. The bytes should be able to be
// parsed into the same block on another node.
Bytes() []byte

// Height returns the height of this block in the chain.
Height() uint64
}
  • choices.Decidable – This interface is a superset of every decidable object, such as transactions, blocks, and vertices.
type Decidable interface {
// ID returns a unique ID for this element.
//
// Typically, this is implemented by using a cryptographic hash of a
// binary representation of this element. An element should return the same
// IDs upon repeated calls.
ID() ids.ID

// Accept this element.
//
// This element will be accepted by every correct node in the network.
Accept() error

// Reject this element.
//
// This element will not be accepted by any correct node in the network.
Reject() error

// Status returns this element's current status.
//
// If Accept has been called on an element with this ID, Accepted should be
// returned. Similarly, if Reject has been called on an element with this
// ID, Rejected should be returned. If the contents of this element are
// unknown, then Unknown should be returned. Otherwise, Processing should be
// returned.
Status() Status
}

2. Download the timestampvm code from GitHub.

Step 2: Writing TimestampVM

The following classes are used to write the TimestampVM.The detailed code of the classes is available in downloadable from GitHub, as mentioned in the previous step. We will describe the functionality of each of the classes.

codec.go – required to encode/decode the block into byte representation.

const(
// CodecVersion is the current default codec version
CodecVersion =0
)

// Codecs do serialization and deserialization
var(
Codec codec.Manager
)

funcinit(){
// Create default codec and manager
c := linearcodec.NewDefault()
Codec = codec.NewDefaultManager()

// Register codec to manager with CodecVersion
if err := Codec.RegisterCodec(CodecVersion, c); err !=nil{
panic(err)
}
}

state.go – The State interface defines the database layer and connections. Each VM should define its own database methods. State embeds the BlockState which defines block-related state operations.

var(
// These are prefixes for db keys.
// It's important to set different prefixes for each separate database objects.
singletonStatePrefix =[]byte("singleton")
blockStatePrefix =[]byte("block")

_ State =&state{}
)

// State is a wrapper around avax.SingleTonState and BlockState
// State also exposes a few methods needed for managing database commits and close.
type State interface{
// SingletonState is defined in avalanchego,
// it is used to understand if db is initialized already.
avax.SingletonState
BlockState

Commit()error
Close()error
}

type state struct{
avax.SingletonState
BlockState

baseDB *versiondb.Database
}

funcNewState(db database.Database, vm *VM) State {
// create a new baseDB
baseDB := versiondb.New(db)

// create a prefixed "blockDB" from baseDB
blockDB := prefixdb.New(blockStatePrefix, baseDB)
// create a prefixed "singletonDB" from baseDB
singletonDB := prefixdb.New(singletonStatePrefix, baseDB)

// return state with created sub state components
return&state{
BlockState:NewBlockState(blockDB, vm),
SingletonState: avax.NewSingletonState(singletonDB),
baseDB: baseDB,
}
}

// Commit commits pending operations to baseDB
func(s *state)Commit()error{
return s.baseDB.Commit()
}

// Close closes the underlying base database
func(s *state)Close()error{
return s.baseDB.Close()
}

block_state.go – This interface and its implementation provide storage functions to VM to store and retrieve blocks.

const(
lastAcceptedByte byte=iota
)

const(
// maximum block capacity of the cache
blockCacheSize =8192
)

// persists lastAccepted block IDs with this key
var lastAcceptedKey =[]byte{lastAcceptedByte}

var_ BlockState =&blockState{}

// BlockState defines methods to manage state with Blocks and LastAcceptedIDs.
type BlockState interface{
GetBlock(blkID ids.ID)(*Block,error)
PutBlock(blk *Block)error

GetLastAccepted()(ids.ID,error)
SetLastAccepted(ids.ID)error
}

// blockState implements BlocksState interface with database and cache.
type blockState struct{
// cache to store blocks
blkCache cache.Cacher
// block database
blockDB database.Database
lastAccepted ids.ID

// vm reference
vm *VM
}

// blkWrapper wraps the actual blk bytes and status to persist them together
type blkWrapper struct{
Blk []byte`serialize:"true"`
Status choices.Status `serialize:"true"`
}

// NewBlockState returns BlockState with a new cache and given db
funcNewBlockState(db database.Database, vm *VM) BlockState {
return&blockState{
blkCache:&cache.LRU{Size: blockCacheSize},
blockDB: db,
vm: vm,
}
}

// GetBlock gets Block from either cache or database
func(s *blockState)GetBlock(blkID ids.ID)(*Block,error){
// Check if cache has this blkID
if blkIntf, cached := s.blkCache.Get(blkID); cached {
// there is a key but value is nil, so return an error
if blkIntf ==nil{
returnnil, database.ErrNotFound
}
// We found it return the block in cache
return blkIntf.(*Block),nil
}

// get block bytes from db with the blkID key
wrappedBytes, err := s.blockDB.Get(blkID[:])
if err !=nil{
// we could not find it in the db, let's cache this blkID with nil value
// so next time we try to fetch the same key we can return error
// without hitting the database
if err == database.ErrNotFound {
s.blkCache.Put(blkID,nil)
}
// could not find the block, return error
returnnil, err
}

// first decode/unmarshal the block wrapper so we can have status and block bytes
blkw := blkWrapper{}
if_, err := Codec.Unmarshal(wrappedBytes,&blkw); err !=nil{
returnnil, err
}

// now decode/unmarshal the actual block bytes to block
blk :=&Block{}
if_, err := Codec.Unmarshal(blkw.Blk, blk); err !=nil{
returnnil, err
}

// initialize block with block bytes, status and vm
blk.Initialize(blkw.Blk, blkw.Status, s.vm)

// put block into cache
s.blkCache.Put(blkID, blk)

return blk,nil
}

// PutBlock puts block into both database and cache
func(s *blockState)PutBlock(blk *Block)error{
// create block wrapper with block bytes and status
blkw := blkWrapper{
Blk: blk.Bytes(),
Status: blk.Status(),
}

// encode block wrapper to its byte representation
wrappedBytes, err := Codec.Marshal(CodecVersion,&blkw)
if err !=nil{
return err
}

blkID := blk.ID()
// put actual block to cache, so we can directly fetch it from cache
s.blkCache.Put(blkID, blk)

// put wrapped block bytes into database
return s.blockDB.Put(blkID[:], wrappedBytes)
}

// DeleteBlock deletes block from both cache and database
func(s *blockState)DeleteBlock(blkID ids.ID)error{
s.blkCache.Put(blkID,nil)
return s.blockDB.Delete(blkID[:])
}

// GetLastAccepted returns last accepted block ID
func(s *blockState)GetLastAccepted()(ids.ID,error){
// check if we already have lastAccepted ID in state memory
if s.lastAccepted != ids.Empty {
return s.lastAccepted,nil
}

// get lastAccepted bytes from database with the fixed lastAcceptedKey
lastAcceptedBytes, err := s.blockDB.Get(lastAcceptedKey)
if err !=nil{
return ids.ID{}, err
}
// parse bytes to ID
lastAccepted, err := ids.ToID(lastAcceptedBytes)
if err !=nil{
return ids.ID{}, err
}
// put lastAccepted ID into memory
s.lastAccepted = lastAccepted
return lastAccepted,nil
}

// SetLastAccepted persists lastAccepted ID into both cache and database
func(s *blockState)SetLastAccepted(lastAccepted ids.ID)error{
// if the ID in memory and the given memory are same don't do anything
if s.lastAccepted == lastAccepted {
returnnil
}
// put lastAccepted ID to memory
s.lastAccepted = lastAccepted
// persist lastAccepted ID to database with fixed lastAcceptedKey
return s.blockDB.Put(lastAcceptedKey, lastAccepted[:])
}

block.go – It is used for block implementation. 

There are three important methods here –

  • Verify – This method verifies that a block is valid and stores it in the memory. It is important to store the verified block in the memory and return them in the vm.GetBlock method as shown above.
func (b *Block) Verify() error {
// Get [b]'s parent
parentID := b.Parent()
parent, err := b.vm.getBlock(parentID)
if err != nil {
return errDatabaseGet
}
}
  • Accept – Accept is called by consensus to indicate this block is accepted.
func (b *Block) Accept() error {
b.SetStatus(choices.Accepted) // Change state of this block
blkID := b.ID()

// Persist data
if err := b.vm.state.PutBlock(b); err != nil {
return err
}

// Set last accepted ID to this block ID
if err := b.vm.state.SetLastAccepted(blkID); err != nil {
return err
}

// Delete this block from verified blocks as it's accepted
delete(b.vm.verifiedBlocks, b.ID())

// Commit changes to database
return b.vm.state.Commit()
}
  • Reject – This is called by the consensus to indicate this block is rejected.
func (b *Block) Reject() error {
b.SetStatus(choices.Rejected) // Change state of this block
if err := b.vm.state.PutBlock(b); err != nil {
return err
}
// Delete this block from verified blocks as it's rejected
delete(b.vm.verifiedBlocks, b.ID())
// Commit changes to database
return b.vm.state.Commit()
}

The following methods are required by the snowman.Block interface

// ID returns the ID of this block
func (b *Block) ID() ids.ID { return b.id }

// ParentID returns [b]'s parent's ID
func (b *Block) Parent() ids.ID { return b.PrntID }

// Height returns this block's height. The genesis block has height 0.
func (b *Block) Height() uint64 { return b.Hght }

// Timestamp returns this block's time. The genesis block has time 0.
func (b *Block) Timestamp() time.Time { return time.Unix(b.Tmstmp, 0) }

// Status returns the status of this block
func (b *Block) Status() choices.Status { return b.status }

// Bytes returns the byte repr. of this block
func (b *Block) Bytes() []byte { return b.bytes }

Step 3: Implementation of TimestampVM

Let’s now look at how timestamp VM implements block.ChainVM interface. The complete implementation is done in the vm.go class.

Here we have described the most important functions of the vm.go class.
To initialize the VM, the class calls Initialize function. 

func (vm *VM) Initialize(
ctx *snow.Context,
dbManager manager.Manager,
genesisData []byte,
upgradeData []byte,
configData []byte,
toEngine chan<- common.Message,
_ []*common.Fx,
_ common.AppSender,
) error {
version, err := vm.Version()
if err != nil {
log.Error("error initializing Timestamp VM: %v", err)
return err
}
log.Info("Initializing Timestamp VM", "Version", version)

vm.dbManager = dbManager
vm.ctx = ctx
vm.toEngine = toEngine
vm.verifiedBlocks = make(map[ids.ID]*Block)

// Create new state
vm.state = NewState(vm.dbManager.Current().Database, vm)

// Initialize genesis
if err := vm.initGenesis(genesisData); err != nil {
return err
}

// Get last accepted
lastAccepted, err := vm.state.GetLastAccepted()
if err != nil {
return err
}

ctx.Log.Info("initializing last accepted block as %s", lastAccepted)

// Build off the most recently accepted block
return vm.SetPreference(lastAccepted)
}

This class is also responsible for initializing the genesis block through its initGenesis helper method

func (vm *VM) initGenesis(genesisData []byte) error {
stateInitialized, err := vm.state.IsInitialized()
if err != nil {
return err
}

// if state is already initialized, skip init genesis.
if stateInitialized {
return nil
}

if len(genesisData) > dataLen {
return errBadGenesisBytes
}

// genesisData is a byte slice but each block contains an byte array
// Take the first [dataLen] bytes from genesisData and put them in an array
var genesisDataArr [dataLen]byte
copy(genesisDataArr[:], genesisData)

// Create the genesis block
// Timestamp of genesis block is 0. It has no parent.
genesisBlock, err := vm.NewBlock(ids.Empty, 0, genesisDataArr, time.Unix(0, 0))
if err != nil {
log.Error("error while creating genesis block: %v", err)
return err
}

// Put genesis block to state
if err := vm.state.PutBlock(genesisBlock); err != nil {
log.Error("error while saving genesis block: %v", err)
return err
}

// Accept the genesis block
// Sets [vm.lastAccepted] and [vm.preferred]
if err := genesisBlock.Accept(); err != nil {
return fmt.Errorf("error accepting genesis block: %w", err)
}

// Mark this vm's state as initialized, so we can skip initGenesis in further restarts
if err := vm.state.SetInitialized(); err != nil {
return fmt.Errorf("error while setting db to initialized: %w", err)
}

// Flush VM's database to underlying db
return vm.state.Commit()
}

The class builds a new block and returns it through its BuildBlock method as requested by the consensus engine.

func (vm *VM) BuildBlock() (snowman.Block, error) {
if len(vm.mempool) == 0 { // There is no block to be built
return nil, errNoPendingBlocks
}

// Get the value to put in the new block
value := vm.mempool[0]
vm.mempool = vm.mempool[1:]

// Notify consensus engine that there are more pending data for blocks
// (if that is the case) when done building this block
if len(vm.mempool) > 0 {
defer vm.NotifyBlockReady()
}

// Gets Preferred Block
preferredBlock, err := vm.getBlock(vm.preferred)
if err != nil {
return nil, fmt.Errorf("couldn't get preferred block: %w", err)
}
preferredHeight := preferredBlock.Height()

// Build the block with preferred height
newBlock, err := vm.NewBlock(vm.preferred, preferredHeight+1, value, time.Now())
if err != nil {
return nil, fmt.Errorf("couldn't build block: %w", err)
}

// Verifies block
if err := newBlock.Verify(); err != nil {
return nil, err
}
return newBlock, nil
}

To send messages to the consensus engine, the class uses one of its helper methods, called NotifyBlockReady.

func (vm *VM) NotifyBlockReady() {
select {
case vm.toEngine <- common.PendingTxs:
default:
vm.ctx.Log.Debug("dropping message to consensus engine")
}
}

The block ID is ascertained with the GetBlock method.

func (vm *VM) GetBlock(blkID ids.ID) (snowman.Block, error) { return vm.getBlock(blkID) }

func (vm *VM) getBlock(blkID ids.ID) (*Block, error) {
// If block is in memory, return it.
if blk, exists := vm.verifiedBlocks[blkID]; exists {
return blk, nil
}

return vm.state.GetBlock(blkID)
}

The proposeBlock method adds a piece of data to the mempool and notifies the consensus layer of the blockchain that a new block is ready to be built and voted on

func (vm *VM) proposeBlock(data [dataLen]byte) {
vm.mempool = append(vm.mempool, data)
vm.NotifyBlockReady()
}
  • The NewBlock method creates a new block
func (vm *VM) NewBlock(parentID ids.ID, height uint64, data [dataLen]byte, timestamp time.Time) (*Block, error) {

block := &Block{

PrntID: parentID,

Hght: height,

Tmstmp: timestamp.Unix(),

Dt: data,

}

// Get the byte representation of the block

blockBytes, err := Codec.Marshal(CodecVersion, block)

if err != nil {

return nil, err

}

// Initialize the block by providing it with its byte representation

// and a reference to this VM

block.Initialize(blockBytes, choices.Processing, vm)

return block, nil

}

Step 4: Factory creation

factory.go – VMs should implement the Factory interface. New method in the interface returns a new VM instance.

var_ vms.Factory =&Factory{}

// Factory ...
type Factory struct{}

// New ...
func (f *Factory) New(*snow.Context) (interface{}, error) { return &VM{}, nil }

Step 5: Static API creation

static_service.go – Creates static API

A VM may have a static API, which allows clients to call methods that do not query or update the state of a particular blockchain but rather apply to the VM as a whole. This is analogous to static methods in computer programming. AvalancheGo uses Gorilla’s RPC library to implement HTTP APIs. For each API method, there is:

  • A struct that defines the method’s arguments
  • A struct that defines the method’s return values
  • A method that implements the API method and is parameterized on the above 2 structs

This API method encodes a string to its byte representation using a given encoding scheme. It can be used to encode data that is then put in a block and proposed as the next block for this chain.

For the detailed implementation of static_service.go refer to the static_service.go code.

Step 6: API creation

service.go – Creates non-static API

A VM may also have a non-static HTTP API, which allows clients to query and update the blockchain’s state.This VM’s API has two methods. One allows a client to get a block by its ID. The other allows a client to propose the next block of this blockchain. The blockchain ID in the endpoint changes since every blockchain has a unique ID.

Step 7: Defining the main package

In order to make this VM compatible with go-plugin, we need to define a main package and method, which serves our VM over gRPC so that AvalancheGo can call its methods.

func main() {
log.Root().SetHandler(log.LvlFilterHandler(log.LvlDebug, log.StreamHandler(os.Stderr, log.TerminalFormat())))
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: rpcchainvm.Handshake,
Plugins: map[string]plugin.Plugin{
"vm": rpcchainvm.New(&timestampvm.VM{}),
},

// A non-nil value here enables gRPC serving for this plugin...
GRPCServer: plugin.DefaultGRPCServer,
})
}

Now AvalancheGo’s rpcchainvm can connect to this plugin and calls its methods.

Step 8: Binary execution

This VM has a build script that builds an executable of this VM (when invoked, it runs the main method from above.)

The path to the executable and its name can be provided to the build script via arguments. For example:

./scripts/build.sh ../avalanchego/build/plugins timestampvm

Your VM is now ready.

Endnote

VMs provide a way to isolate the execution of code from the underlying hardware and operating system, which can be useful for a number of reasons. One reason to use VMs on Avalanche is to enable the execution of untrusted code in a controlled environment. By running code in a VM, you can ensure that it cannot access sensitive resources or harm the system in any way, even if the code contains malicious intent. This can be particularly useful for running smart contracts or other code that is executed on the platform. Another reason to use VMs on Avalanche is to enable the execution of code in different environments or configurations. Creating a VM allows you to specify the operating system, runtime environment, and other settings to provide the right code execution environment. This can be useful for testing and debugging purposes or running code requiring specific dependencies or configurations.

Overall, using VMs on Avalanche can help improve the platform’s security, scalability, and flexibility and facilitate a wide range of applications and use cases.

Unlock the full potential of the decentralized world with Avalanche VMs. Contact LeewayHertz’s team of experts to create and run a virtual machine on Avalanche.

Empower your business with the speed, security and scalability of Avalanche blockchain.

Develop your next feature-rich dApps with LeewayHertz.

How to create a virtual machine on Avalanche?

This article will show you how to create a simple Avalanche virtual machine called TimestampVM. Each block of the TimestampVM’s blockchain contains a 32-byte payload and an increasing timestamp at the block creation time. This server can be used to prove that a block of data existed at the time it was created.

Step 1: Prerequisites

1. Interfaces that every VM must implement

  • block.ChainVM– To reach a consensus on linear blockchains, Avalanche uses the Snowman consensus engine. To be compatible with Snowman, a VM must implement the block.ChainVM interface.
type ChainVM interface {
common.VM
Getter
Parser

BuildBlock() (snowman.Block, error)
LastAccepted() (ids.ID, error)
}

// Getter defines the functionality for fetching a block by its ID.
type Getter interface {
// Attempt to load a block.
//
// If the block does not exist, an error should be returned.
//
GetBlock(ids.ID) (snowman.Block, error)
}

// Parser defines the functionality for fetching a block by its bytes.
type Parser interface {
// Attempt to create a block from a stream of bytes.
//
// The block should be represented by the full byte array, without extra
// bytes.
ParseBlock([]byte) (snowman.Block, error)
}
  • common.VM – This is a type that every VM must implement.
type VM interface {
// Contains handlers for VM-to-VM specific messages
AppHandler

// Returns nil if the VM is healthy.
// Periodically called and reported via the node's Health API.
health.Checkable

// Connector represents a handler that is called on connection connect/disconnect
validators.Connector

// Initialize this VM.
// [ctx]: Metadata about this VM.
// [ctx.networkID]: The ID of the network this VM's chain is running on.
// [ctx.chainID]: The unique ID of the chain this VM is running on.
// [ctx.Log]: Used to log messages
// [ctx.NodeID]: The unique staker ID of this node.
// [ctx.Lock]: A Read/Write lock shared by this VM and the consensus
// engine that manages this VM. The write lock is held
// whenever code in the consensus engine calls the VM.
// [dbManager]: The manager of the database this VM will persist data to.
// [genesisBytes]: The byte-encoding of the genesis information of this
// VM. The VM uses it to initialize its state. For
// example, if this VM were an account-based payments
// system, `genesisBytes` would probably contain a genesis
// transaction that gives coins to some accounts, and this
// transaction would be in the genesis block.
// [toEngine]: The channel used to send messages to the consensus engine.
// [fxs]: Feature extensions that attach to this VM.
Initialize(
ctx *snow.Context,
dbManager manager.Manager,
genesisBytes []byte,
upgradeBytes []byte,
configBytes []byte,
toEngine chan<- Message,
fxs []*Fx,
appSender AppSender,
) error

// Bootstrapping is called when the node is starting to bootstrap this chain.
Bootstrapping() error

// Bootstrapped is called when the node is done bootstrapping this chain.
Bootstrapped() error

// Shutdown is called when the node is shutting down.
Shutdown() error

// Version returns the version of the VM this node is running.
Version() (string, error)

// Creates the HTTP handlers for custom VM network calls.
//
// This exposes handlers that the outside world can use to communicate with
// a static reference to the VM. Each handler has the path:
// [Address of node]/ext/VM/[VM ID]/[extension]
//
// Returns a mapping from [extension]s to HTTP handlers.
//
// Each extension can specify how locking is managed for convenience.
//
// For example, it might make sense to have an extension for creating
// genesis bytes this VM can interpret.
CreateStaticHandlers() (map[string]*HTTPHandler, error)

// Creates the HTTP handlers for custom chain network calls.
//
// This exposes handlers that the outside world can use to communicate with
// the chain. Each handler has the path:
// [Address of node]/ext/bc/[chain ID]/[extension]
//
// Returns a mapping from [extension]s to HTTP handlers.
//
// Each extension can specify how locking is managed for convenience.
//
// For example, if this VM implements an account-based payments system,
// it have an extension called `accounts`, where clients could get
// information about their accounts.
CreateHandlers() (map[string]*HTTPHandler, error)
}
  • snowman.Block – The snowman.Block interface defines the functionality a block must implement to be a block in a linear Snowman chain.
type Block interface {
choices.Decidable

// Parent returns the ID of this block's parent.
Parent() ids.ID

// Verify that the state transition this block would make if accepted is
// valid. If the state transition is invalid, a non-nil error should be
// returned.
//
// It is guaranteed that the Parent has been successfully verified.
Verify() error

// Bytes returns the binary representation of this block.
//
// This is used for sending blocks to peers. The bytes should be able to be
// parsed into the same block on another node.
Bytes() []byte

// Height returns the height of this block in the chain.
Height() uint64
}
  • choices.Decidable – This interface is a superset of every decidable object, such as transactions, blocks, and vertices.
type Decidable interface {
// ID returns a unique ID for this element.
//
// Typically, this is implemented by using a cryptographic hash of a
// binary representation of this element. An element should return the same
// IDs upon repeated calls.
ID() ids.ID

// Accept this element.
//
// This element will be accepted by every correct node in the network.
Accept() error

// Reject this element.
//
// This element will not be accepted by any correct node in the network.
Reject() error

// Status returns this element's current status.
//
// If Accept has been called on an element with this ID, Accepted should be
// returned. Similarly, if Reject has been called on an element with this
// ID, Rejected should be returned. If the contents of this element are
// unknown, then Unknown should be returned. Otherwise, Processing should be
// returned.
Status() Status
}

2. Download the timestampvm code from GitHub.

Step 2: Writing TimestampVM

The following classes are used to write the TimestampVM.The detailed code of the classes is available in downloadable from GitHub, as mentioned in the previous step. We will describe the functionality of each of the classes.

codec.go – required to encode/decode the block into byte representation.

const(
// CodecVersion is the current default codec version
CodecVersion =0
)

// Codecs do serialization and deserialization
var(
Codec codec.Manager
)

funcinit(){
// Create default codec and manager
c := linearcodec.NewDefault()
Codec = codec.NewDefaultManager()

// Register codec to manager with CodecVersion
if err := Codec.RegisterCodec(CodecVersion, c); err !=nil{
panic(err)
}
}

state.go – The State interface defines the database layer and connections. Each VM should define its own database methods. State embeds the BlockState which defines block-related state operations.

var(
// These are prefixes for db keys.
// It's important to set different prefixes for each separate database objects.
singletonStatePrefix =[]byte("singleton")
blockStatePrefix =[]byte("block")

_ State =&state{}
)

// State is a wrapper around avax.SingleTonState and BlockState
// State also exposes a few methods needed for managing database commits and close.
type State interface{
// SingletonState is defined in avalanchego,
// it is used to understand if db is initialized already.
avax.SingletonState
BlockState

Commit()error
Close()error
}

type state struct{
avax.SingletonState
BlockState

baseDB *versiondb.Database
}

funcNewState(db database.Database, vm *VM) State {
// create a new baseDB
baseDB := versiondb.New(db)

// create a prefixed "blockDB" from baseDB
blockDB := prefixdb.New(blockStatePrefix, baseDB)
// create a prefixed "singletonDB" from baseDB
singletonDB := prefixdb.New(singletonStatePrefix, baseDB)

// return state with created sub state components
return&state{
BlockState:NewBlockState(blockDB, vm),
SingletonState: avax.NewSingletonState(singletonDB),
baseDB: baseDB,
}
}

// Commit commits pending operations to baseDB
func(s *state)Commit()error{
return s.baseDB.Commit()
}

// Close closes the underlying base database
func(s *state)Close()error{
return s.baseDB.Close()
}

block_state.go – This interface and its implementation provide storage functions to VM to store and retrieve blocks.

const(
lastAcceptedByte byte=iota
)

const(
// maximum block capacity of the cache
blockCacheSize =8192
)

// persists lastAccepted block IDs with this key
var lastAcceptedKey =[]byte{lastAcceptedByte}

var_ BlockState =&blockState{}

// BlockState defines methods to manage state with Blocks and LastAcceptedIDs.
type BlockState interface{
GetBlock(blkID ids.ID)(*Block,error)
PutBlock(blk *Block)error

GetLastAccepted()(ids.ID,error)
SetLastAccepted(ids.ID)error
}

// blockState implements BlocksState interface with database and cache.
type blockState struct{
// cache to store blocks
blkCache cache.Cacher
// block database
blockDB database.Database
lastAccepted ids.ID

// vm reference
vm *VM
}

// blkWrapper wraps the actual blk bytes and status to persist them together
type blkWrapper struct{
Blk []byte`serialize:"true"`
Status choices.Status `serialize:"true"`
}

// NewBlockState returns BlockState with a new cache and given db
funcNewBlockState(db database.Database, vm *VM) BlockState {
return&blockState{
blkCache:&cache.LRU{Size: blockCacheSize},
blockDB: db,
vm: vm,
}
}

// GetBlock gets Block from either cache or database
func(s *blockState)GetBlock(blkID ids.ID)(*Block,error){
// Check if cache has this blkID
if blkIntf, cached := s.blkCache.Get(blkID); cached {
// there is a key but value is nil, so return an error
if blkIntf ==nil{
returnnil, database.ErrNotFound
}
// We found it return the block in cache
return blkIntf.(*Block),nil
}

// get block bytes from db with the blkID key
wrappedBytes, err := s.blockDB.Get(blkID[:])
if err !=nil{
// we could not find it in the db, let's cache this blkID with nil value
// so next time we try to fetch the same key we can return error
// without hitting the database
if err == database.ErrNotFound {
s.blkCache.Put(blkID,nil)
}
// could not find the block, return error
returnnil, err
}

// first decode/unmarshal the block wrapper so we can have status and block bytes
blkw := blkWrapper{}
if_, err := Codec.Unmarshal(wrappedBytes,&blkw); err !=nil{
returnnil, err
}

// now decode/unmarshal the actual block bytes to block
blk :=&Block{}
if_, err := Codec.Unmarshal(blkw.Blk, blk); err !=nil{
returnnil, err
}

// initialize block with block bytes, status and vm
blk.Initialize(blkw.Blk, blkw.Status, s.vm)

// put block into cache
s.blkCache.Put(blkID, blk)

return blk,nil
}

// PutBlock puts block into both database and cache
func(s *blockState)PutBlock(blk *Block)error{
// create block wrapper with block bytes and status
blkw := blkWrapper{
Blk: blk.Bytes(),
Status: blk.Status(),
}

// encode block wrapper to its byte representation
wrappedBytes, err := Codec.Marshal(CodecVersion,&blkw)
if err !=nil{
return err
}

blkID := blk.ID()
// put actual block to cache, so we can directly fetch it from cache
s.blkCache.Put(blkID, blk)

// put wrapped block bytes into database
return s.blockDB.Put(blkID[:], wrappedBytes)
}

// DeleteBlock deletes block from both cache and database
func(s *blockState)DeleteBlock(blkID ids.ID)error{
s.blkCache.Put(blkID,nil)
return s.blockDB.Delete(blkID[:])
}

// GetLastAccepted returns last accepted block ID
func(s *blockState)GetLastAccepted()(ids.ID,error){
// check if we already have lastAccepted ID in state memory
if s.lastAccepted != ids.Empty {
return s.lastAccepted,nil
}

// get lastAccepted bytes from database with the fixed lastAcceptedKey
lastAcceptedBytes, err := s.blockDB.Get(lastAcceptedKey)
if err !=nil{
return ids.ID{}, err
}
// parse bytes to ID
lastAccepted, err := ids.ToID(lastAcceptedBytes)
if err !=nil{
return ids.ID{}, err
}
// put lastAccepted ID into memory
s.lastAccepted = lastAccepted
return lastAccepted,nil
}

// SetLastAccepted persists lastAccepted ID into both cache and database
func(s *blockState)SetLastAccepted(lastAccepted ids.ID)error{
// if the ID in memory and the given memory are same don't do anything
if s.lastAccepted == lastAccepted {
returnnil
}
// put lastAccepted ID to memory
s.lastAccepted = lastAccepted
// persist lastAccepted ID to database with fixed lastAcceptedKey
return s.blockDB.Put(lastAcceptedKey, lastAccepted[:])
}

block.go – It is used for block implementation. 

There are three important methods here –

  • Verify – This method verifies that a block is valid and stores it in the memory. It is important to store the verified block in the memory and return them in the vm.GetBlock method as shown above.
func (b *Block) Verify() error {
// Get [b]'s parent
parentID := b.Parent()
parent, err := b.vm.getBlock(parentID)
if err != nil {
return errDatabaseGet
}
}
  • Accept – Accept is called by consensus to indicate this block is accepted.
func (b *Block) Accept() error {
b.SetStatus(choices.Accepted) // Change state of this block
blkID := b.ID()

// Persist data
if err := b.vm.state.PutBlock(b); err != nil {
return err
}

// Set last accepted ID to this block ID
if err := b.vm.state.SetLastAccepted(blkID); err != nil {
return err
}

// Delete this block from verified blocks as it's accepted
delete(b.vm.verifiedBlocks, b.ID())

// Commit changes to database
return b.vm.state.Commit()
}
  • Reject – This is called by the consensus to indicate this block is rejected.
func (b *Block) Reject() error {
b.SetStatus(choices.Rejected) // Change state of this block
if err := b.vm.state.PutBlock(b); err != nil {
return err
}
// Delete this block from verified blocks as it's rejected
delete(b.vm.verifiedBlocks, b.ID())
// Commit changes to database
return b.vm.state.Commit()
}

The following methods are required by the snowman.Block interface

// ID returns the ID of this block
func (b *Block) ID() ids.ID { return b.id }

// ParentID returns [b]'s parent's ID
func (b *Block) Parent() ids.ID { return b.PrntID }

// Height returns this block's height. The genesis block has height 0.
func (b *Block) Height() uint64 { return b.Hght }

// Timestamp returns this block's time. The genesis block has time 0.
func (b *Block) Timestamp() time.Time { return time.Unix(b.Tmstmp, 0) }

// Status returns the status of this block
func (b *Block) Status() choices.Status { return b.status }

// Bytes returns the byte repr. of this block
func (b *Block) Bytes() []byte { return b.bytes }

Step 3: Implementation of TimestampVM

Let’s now look at how timestamp VM implements block.ChainVM interface. The complete implementation is done in the vm.go class.

Here we have described the most important functions of the vm.go class.
To initialize the VM, the class calls Initialize function. 

func (vm *VM) Initialize(
ctx *snow.Context,
dbManager manager.Manager,
genesisData []byte,
upgradeData []byte,
configData []byte,
toEngine chan<- common.Message,
_ []*common.Fx,
_ common.AppSender,
) error {
version, err := vm.Version()
if err != nil {
log.Error("error initializing Timestamp VM: %v", err)
return err
}
log.Info("Initializing Timestamp VM", "Version", version)

vm.dbManager = dbManager
vm.ctx = ctx
vm.toEngine = toEngine
vm.verifiedBlocks = make(map[ids.ID]*Block)

// Create new state
vm.state = NewState(vm.dbManager.Current().Database, vm)

// Initialize genesis
if err := vm.initGenesis(genesisData); err != nil {
return err
}

// Get last accepted
lastAccepted, err := vm.state.GetLastAccepted()
if err != nil {
return err
}

ctx.Log.Info("initializing last accepted block as %s", lastAccepted)

// Build off the most recently accepted block
return vm.SetPreference(lastAccepted)
}

This class is also responsible for initializing the genesis block through its initGenesis helper method

func (vm *VM) initGenesis(genesisData []byte) error {
stateInitialized, err := vm.state.IsInitialized()
if err != nil {
return err
}

// if state is already initialized, skip init genesis.
if stateInitialized {
return nil
}

if len(genesisData) > dataLen {
return errBadGenesisBytes
}

// genesisData is a byte slice but each block contains an byte array
// Take the first [dataLen] bytes from genesisData and put them in an array
var genesisDataArr [dataLen]byte
copy(genesisDataArr[:], genesisData)

// Create the genesis block
// Timestamp of genesis block is 0. It has no parent.
genesisBlock, err := vm.NewBlock(ids.Empty, 0, genesisDataArr, time.Unix(0, 0))
if err != nil {
log.Error("error while creating genesis block: %v", err)
return err
}

// Put genesis block to state
if err := vm.state.PutBlock(genesisBlock); err != nil {
log.Error("error while saving genesis block: %v", err)
return err
}

// Accept the genesis block
// Sets [vm.lastAccepted] and [vm.preferred]
if err := genesisBlock.Accept(); err != nil {
return fmt.Errorf("error accepting genesis block: %w", err)
}

// Mark this vm's state as initialized, so we can skip initGenesis in further restarts
if err := vm.state.SetInitialized(); err != nil {
return fmt.Errorf("error while setting db to initialized: %w", err)
}

// Flush VM's database to underlying db
return vm.state.Commit()
}

The class builds a new block and returns it through its BuildBlock method as requested by the consensus engine.

func (vm *VM) BuildBlock() (snowman.Block, error) {
if len(vm.mempool) == 0 { // There is no block to be built
return nil, errNoPendingBlocks
}

// Get the value to put in the new block
value := vm.mempool[0]
vm.mempool = vm.mempool[1:]

// Notify consensus engine that there are more pending data for blocks
// (if that is the case) when done building this block
if len(vm.mempool) > 0 {
defer vm.NotifyBlockReady()
}

// Gets Preferred Block
preferredBlock, err := vm.getBlock(vm.preferred)
if err != nil {
return nil, fmt.Errorf("couldn't get preferred block: %w", err)
}
preferredHeight := preferredBlock.Height()

// Build the block with preferred height
newBlock, err := vm.NewBlock(vm.preferred, preferredHeight+1, value, time.Now())
if err != nil {
return nil, fmt.Errorf("couldn't build block: %w", err)
}

// Verifies block
if err := newBlock.Verify(); err != nil {
return nil, err
}
return newBlock, nil
}

To send messages to the consensus engine, the class uses one of its helper methods, called NotifyBlockReady.

func (vm *VM) NotifyBlockReady() {
select {
case vm.toEngine <- common.PendingTxs:
default:
vm.ctx.Log.Debug("dropping message to consensus engine")
}
}

The block ID is ascertained with the GetBlock method.

func (vm *VM) GetBlock(blkID ids.ID) (snowman.Block, error) { return vm.getBlock(blkID) }

func (vm *VM) getBlock(blkID ids.ID) (*Block, error) {
// If block is in memory, return it.
if blk, exists := vm.verifiedBlocks[blkID]; exists {
return blk, nil
}

return vm.state.GetBlock(blkID)
}

The proposeBlock method adds a piece of data to the mempool and notifies the consensus layer of the blockchain that a new block is ready to be built and voted on

func (vm *VM) proposeBlock(data [dataLen]byte) {
vm.mempool = append(vm.mempool, data)
vm.NotifyBlockReady()
}
  • The NewBlock method creates a new block
func (vm *VM) NewBlock(parentID ids.ID, height uint64, data [dataLen]byte, timestamp time.Time) (*Block, error) {

block := &Block{

PrntID: parentID,

Hght: height,

Tmstmp: timestamp.Unix(),

Dt: data,

}

// Get the byte representation of the block

blockBytes, err := Codec.Marshal(CodecVersion, block)

if err != nil {

return nil, err

}

// Initialize the block by providing it with its byte representation

// and a reference to this VM

block.Initialize(blockBytes, choices.Processing, vm)

return block, nil

}

Step 4: Factory creation

factory.go – VMs should implement the Factory interface. New method in the interface returns a new VM instance.

var_ vms.Factory =&Factory{}

// Factory ...
type Factory struct{}

// New ...
func (f *Factory) New(*snow.Context) (interface{}, error) { return &VM{}, nil }

Step 5: Static API creation

static_service.go – Creates static API

A VM may have a static API, which allows clients to call methods that do not query or update the state of a particular blockchain but rather apply to the VM as a whole. This is analogous to static methods in computer programming. AvalancheGo uses Gorilla’s RPC library to implement HTTP APIs. For each API method, there is:

  • A struct that defines the method’s arguments
  • A struct that defines the method’s return values
  • A method that implements the API method and is parameterized on the above 2 structs

This API method encodes a string to its byte representation using a given encoding scheme. It can be used to encode data that is then put in a block and proposed as the next block for this chain.

For the detailed implementation of static_service.go refer to the static_service.go code.

Step 6: API creation

service.go – Creates non-static API

A VM may also have a non-static HTTP API, which allows clients to query and update the blockchain’s state.This VM’s API has two methods. One allows a client to get a block by its ID. The other allows a client to propose the next block of this blockchain. The blockchain ID in the endpoint changes since every blockchain has a unique ID.

Step 7: Defining the main package

In order to make this VM compatible with go-plugin, we need to define a main package and method, which serves our VM over gRPC so that AvalancheGo can call its methods.

func main() {
log.Root().SetHandler(log.LvlFilterHandler(log.LvlDebug, log.StreamHandler(os.Stderr, log.TerminalFormat())))
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: rpcchainvm.Handshake,
Plugins: map[string]plugin.Plugin{
"vm": rpcchainvm.New(&timestampvm.VM{}),
},

// A non-nil value here enables gRPC serving for this plugin...
GRPCServer: plugin.DefaultGRPCServer,
})
}

Now AvalancheGo’s rpcchainvm can connect to this plugin and calls its methods.

Step 8: Binary execution

This VM has a build script that builds an executable of this VM (when invoked, it runs the main method from above.)

The path to the executable and its name can be provided to the build script via arguments. For example:

./scripts/build.sh ../avalanchego/build/plugins timestampvm

Your VM is now ready.

Endnote

VMs provide a way to isolate the execution of code from the underlying hardware and operating system, which can be useful for a number of reasons. One reason to use VMs on Avalanche is to enable the execution of untrusted code in a controlled environment. By running code in a VM, you can ensure that it cannot access sensitive resources or harm the system in any way, even if the code contains malicious intent. This can be particularly useful for running smart contracts or other code that is executed on the platform. Another reason to use VMs on Avalanche is to enable the execution of code in different environments or configurations. Creating a VM allows you to specify the operating system, runtime environment, and other settings to provide the right code execution environment. This can be useful for testing and debugging purposes or running code requiring specific dependencies or configurations.

Overall, using VMs on Avalanche can help improve the platform’s security, scalability, and flexibility and facilitate a wide range of applications and use cases.

Unlock the full potential of the decentralized world with Avalanche VMs. Contact LeewayHertz’s team of experts to create and run a virtual machine on Avalanche.

Author’s Bio

 

Akash Takyar

Akash Takyar LinkedIn
CEO LeewayHertz
Akash Takyar is the founder and CEO of LeewayHertz. With a proven track record of conceptualizing and architecting 100+ user-centric and scalable solutions for startups and enterprises, he brings a deep understanding of both technical and user experience aspects.
Akash's ability to build enterprise-grade technology solutions has garnered the trust of over 30 Fortune 500 companies, including Siemens, 3M, P&G, and Hershey's. Akash is an early adopter of new technology, a passionate technology enthusiast, and an investor in AI and IoT startups.

Start a conversation by filling the form

Once you let us know your requirement, our technical expert will schedule a call and discuss your idea in detail post sign of an NDA.
All information will be kept confidential.

Insights

Follow Us