How to Create a Virtual Machine on Avalanche
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(×tampvm.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(×tampvm.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.
Start a conversation by filling the form
All information will be kept confidential.
Insights
DeFi asset tokenization: Unlocking new possibilities
DeFi asset tokenization is the next step in the evolution of securitization, made possible by blockchain technology.
Wrapped tokens: An innovative approach to interoperability
Learn how wrapped tokens play a critical role in enabling cross-chain interoperability and in providing new financial services within the blockchain ecosystem.
What are synthetic assets in decentralized finance
Crypto synthetic assets are gaining popularity in the crypto world as they allow investors to benefit from token fluctuations without actually owning them.