How to Build a Complex Golang VM
This is part of a series of tutorials for building a Virtual Machine (VM):
- Introduction to Virtual Machines
- How to Build a Simple Golang VM
- How to Build a Complex Golang VM (this article)
Introduction
In this tutorial, we'll walk through how to build a virtual machine by referencing the BlobVM. The BlobVM is a virtual machine that can be used to implement a decentralized key-value store.
A blob (shorthand for "binary large object") is an arbitrary piece of data. BlobVM stores a key-value pair by breaking it apart into multiple chunks stored with their hashes as their keys in the blockchain. A root key-value pair has references to these chunks for lookups. By default, the maximum chunk size is set to 200 KiB.
Prerequisites
Make sure you have followed the previous tutorials in this series:
Components
A VM defines how a blockchain should be built. A block is populated with a set of transactions which mutate the state of the blockchain when executed. When a block with a set of transactions is applied to a given state, a state transition occurs by executing all of the transactions in the block in-order and applying it to the previous block of the blockchain. By executing a series of blocks chronologically, anyone can verify and reconstruct the state of the blockchain at an arbitrary point in time.
The BlobVM repository has a few components to handle the lifecycle of tasks from a transaction being issued to a block being accepted across the network:
- Transaction - A state transition
- Mempool - Stores pending transactions that haven't been finalized yet
- Network - Propagates transactions from the mempool other nodes in the network
- Block - Defines the block format, how to verify it, and how it should be accepted or rejected across the network
- Block Builder - Builds blocks by including transactions from the mempool
- Virtual Machine - Application-level logic. Implements the VM interface needed to interact with Avalanche consensus and defines the blueprint for the blockchain.
- Service - Exposes APIs so users can interact with the VM
- Factory - Used to initialize the VM
Lifecycle of a Transaction
A VM will often times expose a set of APIs so users can interact with the it. In the blockchain, blocks can contain a set of transactions which mutate the blockchain's state. Let's dive into the lifecycle of a transaction from its issuance to its finalization on the blockchain.
- A user makes an API request to
service.IssueRawTx
to issue their transaction- This API will deserialize the user's transaction and forward it to the VM
- The transaction is submitted to the VM
- The transaction is added to the VM's mempool
- The VM asynchronously periodically gossips new transactions in its mempool to other nodes in the network so they can learn about them
- The VM sends the Avalanche consensus engine a message to indicate that it has transactions in the mempool that are ready to be built into a block
- The VM proposes the block with to consensus
- Consensus verifies that the block is valid and well-formed
- Consensus gets the network to vote on whether the block should be accepted or rejected
- If a block is rejected, its transactions are reclaimed by the mempool so they can be included in a future block
- If a block is accepted, it's finalized by writing it to the blockchain
Coding the Virtual Machine
We'll dive into a few of the packages that are in the The BlobVM repository to learn more about how they work:
vm
block_builder.go
chain_vm.go
network.go
service.go
vm.go
chain
unsigned_tx.go
base_tx.go
transfer_tx.go
set_tx.go
tx.go
block.go
mempool.go
storage.go
builder.go
mempool
mempool.go
Transactions
The state the blockchain can only be mutated by getting the network to accept a signed transaction. A signed transaction contains the transaction to be executed alongside the signature of the issuer. The signature is required to cryptographically verify the sender's identity. A VM can define an arbitrary amount of unique transactions types to support different operations on the blockchain. The BlobVM implements two different transactions types:
- TransferTx - Transfers coins between accounts.
- SetTx - Stores a key-value pair on the blockchain.
UnsignedTransaction
All transactions in the BlobVM implement the common
UnsignedTransaction
interface, which exposes shared functionality for all transaction types.
type UnsignedTransaction interface {
Copy() UnsignedTransaction
GetBlockID() ids.ID
GetMagic() uint64
GetPrice() uint64
SetBlockID(ids.ID)
SetMagic(uint64)
SetPrice(uint64)
FeeUnits(*Genesis) uint64 // number of units to mine tx
LoadUnits(*Genesis) uint64 // units that should impact fee rate
ExecuteBase(*Genesis) error
Execute(*TransactionContext) error
TypedData() *tdata.TypedData
Activity() *Activity
}
BaseTx
Common functionality and metadata for transaction types are implemented by BaseTx
.
SetBlockID
sets the transaction's block ID.GetBlockID
returns the transaction's block ID.SetMagic
sets the magic number. The magic number is used to differentiate chains to prevent replay attacksGetMagic
returns the magic number. Magic number is defined in genesis.SetPrice
sets the price per fee unit for this transaction.GetPrice
returns the price for this transaction.FeeUnits
returns the fee units this transaction will consume.LoadUnits
identical toFeeUnits
ExecuteBase
executes common validation checks across different transaction types. This validates the transaction contains a valid block ID, magic number, and gas price as defined by genesis.
TransferTx
TransferTx
supports the
transfer of tokens from one account to another.
type TransferTx struct {
*BaseTx `serialize:"true" json:"baseTx"`
// To is the recipient of the [Units].
To common.Address `serialize:"true" json:"to"`
// Units are transferred to [To].
Units uint64 `serialize:"true" json:"units"`
}
TransferTx
embeds BaseTx
to avoid re-implementing common operations with other transactions, but
implements its own Execute
to support token transfers.
This performs a few checks to ensure that the transfer is valid before transferring the tokens between the two accounts.
func (t *TransferTx) Execute(c *TransactionContext) error {
// Must transfer to someone
if bytes.Equal(t.To[:], zeroAddress[:]) {
return ErrNonActionable
}
// This prevents someone from transferring to themselves.
if bytes.Equal(t.To[:], c.Sender[:]) {
return ErrNonActionable
}
if t.Units == 0 {
return ErrNonActionable
}
if _, err := ModifyBalance(c.Database, c.Sender, false, t.Units); err != nil {
return err
}
if _, err := ModifyBalance(c.Database, t.To, true, t.Units); err != nil {
return err
}
return nil
}
SetTx
SetTx
is used to assign a value to a key.
type SetTx struct {
*BaseTx `serialize:"true" json:"baseTx"`
Value []byte `serialize:"true" json:"value"`
}
SetTx
implements its own
FeeUnits
method to
compensate the network according to the size of the blob being stored.
func (s *SetTx) FeeUnits(g *Genesis) uint64 {
// We don't subtract by 1 here because we want to charge extra for any
// value-based interaction (even if it is small or a delete).
return s.BaseTx.FeeUnits(g) + valueUnits(g, uint64(len(s.Value)))
}
SetTx
's Execute
method
performs a few safety checks to validate that the blob meets the size constraints enforced by
genesis and doesn't overwrite an existing key before writing it to the blockchain.
func (s *SetTx) Execute(t *TransactionContext) error {
g := t.Genesis
switch {
case len(s.Value) == 0:
return ErrValueEmpty
case uint64(len(s.Value)) > g.MaxValueSize:
return ErrValueTooBig
}
k := ValueHash(s.Value)
// Do not allow duplicate value setting
_, exists, err := GetValueMeta(t.Database, k)
if err != nil {
return err
}
if exists {
return ErrKeyExists
}
return PutKey(t.Database, k, &ValueMeta{
Size: uint64(len(s.Value)),
TxID: t.TxID,
Created: t.BlockTime,
})
}
Signed Transaction
The unsigned transactions mentioned previously can't be issued to the network without first being
signed. BlobVM implements signed transactions by embedding the unsigned transaction alongside its
signature in Transaction
. In BlobVM,
a signature is defined as the
ECDSA signature of the
issuer's private key of the KECCAK256 hash of the unsigned
transaction's data (digest hash).
type Transaction struct {
UnsignedTransaction `serialize:"true" json:"unsignedTransaction"`
Signature []byte `serialize:"true" json:"signature"`
digestHash []byte
bytes []byte
id ids.ID
size uint64
sender common.Address
}
The Transaction
type wraps any unsigned transaction. When a Transaction
is executed, it calls the
Execute
method of the underlying embedded UnsignedTx
and performs the following sanity checks:
- The underlying
UnsignedTx
must meet the requirements set by genesis- This includes checks to make sure that the transaction contains the correct magic number and meets the minimum gas price as defined by genesis
- The transaction's block ID must be a recently accepted block
- The transaction must not be a recently issued transaction
- The issuer of the transaction must have enough gas
- The transaction's gas price must be meet the next expected block's minimum gas price
- The transaction must execute without error
If the transaction is successfully verified, it's submitted as a pending write to the blockchain.
func (t *Transaction) Execute(g *Genesis, db database.Database, blk *StatelessBlock, context *Context) error {
if err := t.UnsignedTransaction.ExecuteBase(g); err != nil {
return err
}
if !context.RecentBlockIDs.Contains(t.GetBlockID()) {
// Hash must be recent to be any good
// Should not happen beause of mempool cleanup
return ErrInvalidBlockID
}
if context.RecentTxIDs.Contains(t.ID()) {
// Tx hash must not be recently executed (otherwise could be replayed)
//
// NOTE: We only need to keep cached tx hashes around as long as the
// block hash referenced in the tx is valid
return ErrDuplicateTx
}
// Ensure sender has balance
if _, err := ModifyBalance(db, t.sender, false, t.FeeUnits(g)*t.GetPrice()); err != nil {
return err
}
if t.GetPrice() < context.NextPrice {
return ErrInsufficientPrice
}
if err := t.UnsignedTransaction.Execute(&TransactionContext{
Genesis: g,
Database: db,
BlockTime: uint64(blk.Tmstmp),
TxID: t.id,
Sender: t.sender,
}); err != nil {
return err
}
if err := SetTransaction(db, t); err != nil {
return err
}
return nil
}
Example
Let's walk through an example on how to issue a SetTx
transaction to the BlobVM to write a key-value
pair.
- Create the unsigned transaction for
SetTx
utx := &chain.SetTx{
BaseTx: &chain.BaseTx{},
Value: []byte("data"),
}
utx.SetBlockID(lastAcceptedID)
utx.SetMagic(genesis.Magic)
utx.SetPrice(price + blockCost/utx.FeeUnits(genesis))
- Calculate the digest hash for the transaction.
digest, err := chain.DigestHash(utx)
- Sign the digest hash with the issuer's private key.
signature, err := chain.Sign(digest, privateKey)
- Create and initialize the new signed transaction.
tx := chain.NewTx(utx, sig)
if err := tx.Init(g); err != nil {
return ids.Empty, 0, err
}
- Issue the request with the client
txID, err = cli.IssueRawTx(ctx, tx.Bytes())
Mempool
Mempool Overview
The mempool is a buffer of volatile memory that stores pending transactions. Transactions are stored in the mempool whenever a node learns about a new transaction either through gossip with other nodes or through an API call issued by a user.
The mempool is implemented as a min-max heap ordered by each transaction's gas price. The mempool is created during the initialization of VM.
vm.mempool = mempool.New(vm.genesis, vm.config.MempoolSize)
Whenever a transaction is submitted to VM, it first gets initialized, verified, and executed locally. If the transaction looks valid, then it's added to the mempool.
vm.mempool.Add(tx)
Mempool Methods
Add
When a transaction is added to the mempool,
Add
is called. This
performs the following:
- Checks if the transaction being added already exists in the mempool or not
- The transaction is added to the min-max heap
- If the mempool's heap size is larger than the maximum configured value, then the lowest paying transaction is evicted
- The transaction is added to the list of transactions that are able to be gossiped to other peers
- A notification is sent through the in the
mempool.Pending
channel to indicate that the consensus engine should build a new block
Block Builder
Block Builder Overview
The TimeBuilder
implementation for BlockBuilder
acts as an intermediary notification service between the mempool
and the consensus engine. It serves the following functions:
- Periodically gossips new transactions to other nodes in the network
- Periodically notifies the consensus engine that new transactions from the mempool are ready to be built into blocks
TimeBuilder
and can exist in 3 states:
dontBuild
- There are no transactions in the mempool that are ready to be included in a blockbuilding
- The consensus engine has been notified that it should build a block and there are currently transactions in the mempool that are eligible to be included into a blockmayBuild
- There are transactions in the mempool that are eligible to be included into a block, but the consensus engine has not been notified yet
Block Builder Methods
Gossip
The Gossip
method
initiates the gossip of new transactions from the mempool at periodically as defined by vm.config.GossipInterval
.
Build
The Build
method
consumes transactions from the mempool and signals the consensus engine when it's ready to build a block.
If the mempool signals the TimeBuilder
that it has available transactions, TimeBuilder
will
signal consensus that the VM is ready to build a block by sending the consensus engine a
common.PendingTxs
message.
When the consensus engine receives the common.PendingTxs
message it calls the VM's BuildBlock
method. The VM will then build a block from eligible transactions in the mempool.
- If there are still remaining transactions in the mempool after a block is built, then the
TimeBuilder
is put into themayBuild
state to indicate that there are still transactions that are eligible to be built into block, but the consensus engine isn't aware of it yet.
Network
Network handles the workflow of gossiping transactions from a node's mempool to other nodes in the network.
Network Methods
GossipNewTxs
GossipNewTxs
sends a list of transactions to other nodes in the network. TimeBuilder
calls the
network's GossipNewTxs
function to gossip new transactions in the mempool.
func (n *PushNetwork) GossipNewTxs(newTxs []*chain.Transaction) error {
txs := []*chain.Transaction{}
// Gossip at most the target units of a block at once
for _, tx := range newTxs {
if _, exists := n.gossipedTxs.Get(tx.ID()); exists {
log.Debug("already gossiped, skipping", "txId", tx.ID())
continue
}
n.gossipedTxs.Put(tx.ID(), nil)
txs = append(txs, tx)
}
return n.sendTxs(txs)
}
Recently gossiped transactions are maintained in a cache to avoid DDoSing a node from repeated gossip failures.
Other nodes in the network will receive the gossiped transactions through their AppGossip
handler.
Once a gossip message is received, it's deserialized and the new transactions are submitted to the VM.
func (vm *VM) AppGossip(nodeID ids.NodeID, msg []byte) error {
txs := make([]*chain.Transaction, 0)
if _, err := chain.Unmarshal(msg, &txs); err != nil {
return nil
}
// submit incoming gossip
log.Debug("AppGossip transactions are being submitted", "txs", len(txs))
if errs := vm.Submit(txs...); len(errs) > 0 {
for _, err := range errs {
}
}
return nil
}
Block
Blocks go through a lifecycle of being proposed by a validator, verified, and decided by consensus. Upon acceptance, a block will be committed and will be finalized on the blockchain.
BlobVM implements two types of blocks, StatefulBlock
and StatelessBlock
.
StatefulBlock
A StatefulBlock
contains
strictly the metadata about the block that needs to be written to the database.
type StatefulBlock struct {
Prnt ids.ID `serialize:"true" json:"parent"`
Tmstmp int64 `serialize:"true" json:"timestamp"`
Hght uint64 `serialize:"true" json:"height"`
Price uint64 `serialize:"true" json:"price"`
Cost uint64 `serialize:"true" json:"cost"`
AccessProof common.Hash `serialize:"true" json:"accessProof"`
Txs []*Transaction `serialize:"true" json:"txs"`
}
StatelessBlock
StatelessBlock is a superset of
StatefulBlock
and additionally contains fields that are needed to support block-level operations
like verification and acceptance throughout its lifecycle in the VM.
type StatelessBlock struct {
*StatefulBlock `serialize:"true" json:"block"`
id ids.ID
st choices.Status
t time.Time
bytes []byte
vm VM
children []*StatelessBlock
onAcceptDB *versiondb.Database
}
Let's have a look at the fields of StatelessBlock:
StatefulBlock
- The metadata about the block that will be written to the database upon acceptancebytes
- The serialized form of theStatefulBlock
.id
- The Keccak256 hash ofbytes
.st
- The status of the block in consensus (i.eProcessing
,Accepted
, orRejected
)children
- The children of this blockonAcceptDB
- The database this block should be written to upon acceptance.
When the consensus engine tries to build a block by calling the VM's BuildBlock
, the VM calls the
block.NewBlock
function to
get a new block that is a child of the currently preferred block.
func NewBlock(vm VM, parent snowman.Block, tmstp int64, context *Context) *StatelessBlock {
return &StatelessBlock{
StatefulBlock: &StatefulBlock{
Tmstmp: tmstp,
Prnt: parent.ID(),
Hght: parent.Height() + 1,
Price: context.NextPrice,
Cost: context.NextCost,
},
vm: vm,
st: choices.Processing,
}
}
Some StatelessBlock
fields like the block ID, byte representation, and timestamp aren't populated
immediately. These are set during the StatelessBlock
's
init
method, which
initializes these fields once the block has been populated with transactions.
func (b *StatelessBlock) init() error {
bytes, err := Marshal(b.StatefulBlock)
if err != nil {
return err
}
b.bytes = bytes
id, err := ids.ToID(crypto.Keccak256(b.bytes))
if err != nil {
return err
}
b.id = id
b.t = time.Unix(b.StatefulBlock.Tmstmp, 0)
g := b.vm.Genesis()
for _, tx := range b.StatefulBlock.Txs {
if err := tx.Init(g); err != nil {
return err
}
}
return nil
}
To build the block, the VM will try to remove as many of the highest-paying transactions from the mempool to include them in the new block until the maximum block fee set by genesis is reached.
A block once built, can exist in two states:
- Rejected - The block was not accepted by consensus
- In this case, the mempool will reclaim the rejected block's transactions so they can be included in a future block.
- Accepted - The block was accepted by consensus
- In this case, we write the block to the blockchain by committing it to the database.
When the consensus engine receives the built block, it calls the block's
Verify
method to validate
that the block is well-formed. In BlobVM, the following constraints are placed on valid blocks:
- A block must contain at least one transaction and the block's timestamp must be within 10s into the future.
if len(b.Txs) == 0 {
return nil, nil, ErrNoTxs
}
if b.Timestamp().Unix() >= time.Now().Add(futureBound).Unix() {
return nil, nil, ErrTimestampTooLate
}
- The sum of the gas units consumed by the transactions in the block must not exceed the gas limit defined by genesis.
blockSize := uint64(0)
for _, tx := range b.Txs {
blockSize += tx.LoadUnits(g)
if blockSize > g.MaxBlockSize {
return nil, nil, ErrBlockTooBig
}
}
- The parent block of the proposed block must exist and have an earlier timestamp.
parent, err := b.vm.GetStatelessBlock(b.Prnt)
if err != nil {
log.Debug("could not get parent", "id", b.Prnt)
return nil, nil, err
}
if b.Timestamp().Unix() < parent.Timestamp().Unix() {
return nil, nil, ErrTimestampTooEarly
}
- The target block price and minimum gas price must meet the minimum enforced by the VM.
context, err := b.vm.ExecutionContext(b.Tmstmp, parent)
if err != nil {
return nil, nil, err
}
if b.Cost != context.NextCost {
return nil, nil, ErrInvalidCost
}
if b.Price != context.NextPrice {
return nil, nil, ErrInvalidPrice
}
After the results of consensus are complete, the block is either accepted by committing the block to the database or rejected by returning the block's transactions into the mempool.
// implements "snowman.Block.choices.Decidable"
func (b *StatelessBlock) Accept() error {
if err := b.onAcceptDB.Commit(); err != nil {
return err
}
for _, child := range b.children {
if err := child.onAcceptDB.SetDatabase(b.vm.State()); err != nil {
return err
}
}
b.st = choices.Accepted
b.vm.Accepted(b)
return nil
}
// implements "snowman.Block.choices.Decidable"
func (b *StatelessBlock) Reject() error {
b.st = choices.Rejected
b.vm.Rejected(b)
return nil
}
API
Service implements an API
server so users can interact with the VM. The VM implements the interface method
CreateHandlers
that exposes the
VM's RPC API.
func (vm *VM) CreateHandlers() (map[string]*common.HTTPHandler, error) {
apis := map[string]*common.HTTPHandler{}
public, err := newHandler(Name, &PublicService{vm: vm})
if err != nil {
return nil, err
}
apis[PublicEndpoint] = public
return apis, nil
}
One API that's exposed is
IssueRawTx
to allow
users to issue transactions to the BlobVM. It accepts an IssueRawTxArgs
that contains the
transaction the user wants to issue and forwards it to the VM.
func (svc *PublicService) IssueRawTx(_ *http.Request, args *IssueRawTxArgs, reply *IssueRawTxReply) error {
tx := new(chain.Transaction)
if _, err := chain.Unmarshal(args.Tx, tx); err != nil {
return err
}
// otherwise, unexported tx.id field is empty
if err := tx.Init(svc.vm.genesis); err != nil {
return err
}
reply.TxID = tx.ID()
errs := svc.vm.Submit(tx)
if len(errs) == 0 {
return nil
}
if len(errs) == 1 {
return errs[0]
}
return fmt.Errorf("%v", errs)
}
Virtual Machine
We have learned about all the components used in the BlobVM. Most of these components are referenced
in the vm.go
file, which acts as the entry point for the consensus engine as well as users
interacting with the blockchain. For example, the engine calls vm.BuildBlock()
, that in turn calls
chain.BuildBlock()
. Another example is when a user issues a raw transaction through service APIs,
the vm.Submit()
method is called.
Let's look at some of the important methods of vm.go
that must be implemented:
Virtual Machine Methods
Initialize
Initialize is invoked by metalgo
when creating the blockchain. metalgo
passes some parameters to the implementing VM.
ctx
- Metadata about the VM's executiondbManager
- The database that the VM can write togenesisBytes
- The serialized representation of the genesis state of this VMupgradeBytes
- The serialized representation of network upgradesconfigBytes
- The serialized VM-specific configurationtoEngine
- The channel used to send messages to the consensus enginefxs
- Feature extensions that attach to this VMappSender
- Used to send messages to other nodes in the network
BlobVM upon initialization persists these fields in its own state to use them throughout the lifetime of its execution.
// implements "snowmanblock.ChainVM.common.VM"
func (vm *VM) Initialize(
ctx *snow.Context,
dbManager manager.Manager,
genesisBytes []byte,
upgradeBytes []byte,
configBytes []byte,
toEngine chan<- common.Message,
_ []*common.Fx,
appSender common.AppSender,
) error {
log.Info("initializing blobvm", "version", version.Version)
// Load config
vm.config.SetDefaults()
if len(configBytes) > 0 {
if err := ejson.Unmarshal(configBytes, &vm.config); err != nil {
return fmt.Errorf("failed to unmarshal config %s: %w", string(configBytes), err)
}
}
vm.ctx = ctx
vm.db = dbManager.Current().Database
vm.activityCache = make([]*chain.Activity, vm.config.ActivityCacheSize)
// Init channels before initializing other structs
vm.stop = make(chan struct{})
vm.builderStop = make(chan struct{})
vm.doneBuild = make(chan struct{})
vm.doneGossip = make(chan struct{})
vm.appSender = appSender
vm.network = vm.NewPushNetwork()
vm.blocks = &cache.LRU{Size: blocksLRUSize}
vm.verifiedBlocks = make(map[ids.ID]*chain.StatelessBlock)
vm.toEngine = toEngine
vm.builder = vm.NewTimeBuilder()
// Try to load last accepted
has, err := chain.HasLastAccepted(vm.db)
if err != nil {
log.Error("could not determine if have last accepted")
return err
}
// Parse genesis data
vm.genesis = new(chain.Genesis)
if err := ejson.Unmarshal(genesisBytes, vm.genesis); err != nil {
log.Error("could not unmarshal genesis bytes")
return err
}
if err := vm.genesis.Verify(); err != nil {
log.Error("genesis is invalid")
return err
}
targetUnitsPerSecond := vm.genesis.TargetBlockSize / uint64(vm.genesis.TargetBlockRate)
vm.targetRangeUnits = targetUnitsPerSecond * uint64(vm.genesis.LookbackWindow)
log.Debug("loaded genesis", "genesis", string(genesisBytes), "target range units", vm.targetRangeUnits)
vm.mempool = mempool.New(vm.genesis, vm.config.MempoolSize)
if has { //nolint:nestif
blkID, err := chain.GetLastAccepted(vm.db)
if err != nil {
log.Error("could not get last accepted", "err", err)
return err
}
blk, err := vm.GetStatelessBlock(blkID)
if err != nil {
log.Error("could not load last accepted", "err", err)
return err
}
vm.preferred, vm.lastAccepted = blkID, blk
log.Info("initialized blobvm from last accepted", "block", blkID)
} else {
genesisBlk, err := chain.ParseStatefulBlock(
vm.genesis.StatefulBlock(),
nil,
choices.Accepted,
vm,
)
if err != nil {
log.Error("unable to init genesis block", "err", err)
return err
}
// Set Balances
if err := vm.genesis.Load(vm.db, vm.AirdropData); err != nil {
log.Error("could not set genesis allocation", "err", err)
return err
}
if err := chain.SetLastAccepted(vm.db, genesisBlk); err != nil {
log.Error("could not set genesis as last accepted", "err", err)
return err
}
gBlkID := genesisBlk.ID()
vm.preferred, vm.lastAccepted = gBlkID, genesisBlk
log.Info("initialized blobvm from genesis", "block", gBlkID)
}
vm.AirdropData = nil
After initializing its own state, BlobVM also starts asynchronous workers to build blocks and gossip transactions to the rest of the network.
go vm.builder.Build()
go vm.builder.Gossip()
return nil
}
GetBlock
GetBlock
returns the block with
the provided ID.
GetBlock will attempt to fetch the given block from the database, and return an non-nil error if it wasn't able to get it.
func (vm *VM) GetBlock(id ids.ID) (snowman.Block, error) {
b, err := vm.GetStatelessBlock(id)
if err != nil {
log.Warn("failed to get block", "err", err)
}
return b, err
}
ParseBlock
ParseBlock
deserializes a block.
func (vm *VM) ParseBlock(source []byte) (snowman.Block, error) {
newBlk, err := chain.ParseBlock(
source,
choices.Processing,
vm,
)
if err != nil {
log.Error("could not parse block", "err", err)
return nil, err
}
log.Debug("parsed block", "id", newBlk.ID())
// If we have seen this block before, return it with the most
// up-to-date info
if oldBlk, err := vm.GetBlock(newBlk.ID()); err == nil {
log.Debug("returning previously parsed block", "id", oldBlk.ID())
return oldBlk, nil
}
return newBlk, nil
}
BuildBlock
Avalanche consensus calls BuildBlock
when it receives a notification from the VM that it has pending transactions that are ready to be
issued into a block.
func (vm *VM) BuildBlock() (snowman.Block, error) {
log.Debug("BuildBlock triggered")
blk, err := chain.BuildBlock(vm, vm.preferred)
vm.builder.HandleGenerateBlock()
if err != nil {
log.Debug("BuildBlock failed", "error", err)
return nil, err
}
sblk, ok := blk.(*chain.StatelessBlock)
if !ok {
return nil, fmt.Errorf("unexpected snowman.Block %T, expected *StatelessBlock", blk)
}
log.Debug("BuildBlock success", "blkID", blk.ID(), "txs", len(sblk.Txs))
return blk, nil
}
SetPreference
SetPreference
sets the block ID
preferred by this node. A node votes to accept or reject a block based on its current preference in consensus.
func (vm *VM) SetPreference(id ids.ID) error {
log.Debug("set preference", "id", id)
vm.preferred = id
return nil
}
LastAccepted
LastAccepted returns the block ID of the block that was most recently accepted by this node.
func (vm *VM) LastAccepted() (ids.ID, error) {
return vm.lastAccepted.ID(), nil
}
CLI
BlobVM implements a generic key-value store, but support to read and write arbitrary files into the
BlobVM blockchain is implemented in the blob-cli
To write a file, BlobVM breaks apart an arbitrarily large file into many small chunks. Each chunk is
submitted to the VM in a SetTx
. A root key is generated which contains all of the hashes of the chunks.
func Upload(
ctx context.Context, cli client.Client, priv *ecdsa.PrivateKey,
f io.Reader, chunkSize int,
) (common.Hash, error) {
hashes := []common.Hash{}
chunk := make([]byte, chunkSize)
shouldExit := false
opts := []client.OpOption{client.WithPollTx()}
totalCost := uint64(0)
uploaded := map[common.Hash]struct{}{}
for !shouldExit {
read, err := f.Read(chunk)
if errors.Is(err, io.EOF) || read == 0 {
break
}
if err != nil {
return common.Hash{}, fmt.Errorf("%w: read error", err)
}
if read < chunkSize {
shouldExit = true
chunk = chunk[:read]
// Use small file optimization
if len(hashes) == 0 {
break
}
}
k := chain.ValueHash(chunk)
if _, ok := uploaded[k]; ok {
color.Yellow("already uploaded k=%s, skipping", k)
} else if exists, _, _, err := cli.Resolve(ctx, k); err == nil && exists {
color.Yellow("already on-chain k=%s, skipping", k)
uploaded[k] = struct{}{}
} else {
tx := &chain.SetTx{
BaseTx: &chain.BaseTx{},
Value: chunk,
}
txID, cost, err := client.SignIssueRawTx(ctx, cli, tx, priv, opts...)
if err != nil {
return common.Hash{}, err
}
totalCost += cost
color.Yellow("uploaded k=%s txID=%s cost=%d totalCost=%d", k, txID, cost, totalCost)
uploaded[k] = struct{}{}
}
hashes = append(hashes, k)
}
r := &Root{}
if len(hashes) == 0 {
if len(chunk) == 0 {
return common.Hash{}, ErrEmpty
}
r.Contents = chunk
} else {
r.Children = hashes
}
rb, err := json.Marshal(r)
if err != nil {
return common.Hash{}, err
}
rk := chain.ValueHash(rb)
tx := &chain.SetTx{
BaseTx: &chain.BaseTx{},
Value: rb,
}
txID, cost, err := client.SignIssueRawTx(ctx, cli, tx, priv, opts...)
if err != nil {
return common.Hash{}, err
}
totalCost += cost
color.Yellow("uploaded root=%v txID=%s cost=%d totalCost=%d", rk, txID, cost, totalCost)
return rk, nil
}
Example 1
blob-cli set-file ~/Downloads/computer.gif -> 6fe5a52f52b34fb1e07ba90bad47811c645176d0d49ef0c7a7b4b22013f676c8
Given the root hash, a file can be looked up by deserializing all of its children chunk values and reconstructing the original file.
// TODO: make multi-threaded
func Download(ctx context.Context, cli client.Client, root common.Hash, f io.Writer) error {
exists, rb, _, err := cli.Resolve(ctx, root)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("%w:%v", ErrMissing, root)
}
var r Root
if err := json.Unmarshal(rb, &r); err != nil {
return err
}
// Use small file optimization
if contentLen := len(r.Contents); contentLen > 0 {
if _, err := f.Write(r.Contents); err != nil {
return err
}
color.Yellow("downloaded root=%v size=%fKB", root, float64(contentLen)/units.KiB)
return nil
}
if len(r.Children) == 0 {
return ErrEmpty
}
amountDownloaded := 0
for _, h := range r.Children {
exists, b, _, err := cli.Resolve(ctx, h)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("%w:%s", ErrMissing, h)
}
if _, err := f.Write(b); err != nil {
return err
}
size := len(b)
color.Yellow("downloaded chunk=%v size=%fKB", h, float64(size)/units.KiB)
amountDownloaded += size
}
color.Yellow("download complete root=%v size=%fMB", root, float64(amountDownloaded)/units.MiB)
return nil
}
Example 2
blob-cli resolve-file 6fe5a52f52b34fb1e07ba90bad47811c645176d0d49ef0c7a7b4b22013f676c8 computer_copy.gif
Conclusion
This documentation covers concepts about Virtual Machine by walking through a VM that implements a decentralized key-value store.
You can learn more about the BlobVM by referencing the README in the GitHub repository.