Virtual Machines: An Introduction to Blockchains
In the final part of this series, we'll learn about virtual machines, and what they can do in conjunction with blockchains. We'll:
Learn about virtual machines and smart contracts.
Code a virtual machine.
Integrate the virtual machine into our blockchain.
Run the virtual machine in our blockchain.
This series is comprised of the following posts:
Virtual Machines
Virtual machines (VMs) are software-based emulations of physical computers, allowing multiple operating systems and applications to run concurrently on a single physical machine.
In essence, VMs can take as input an initial system state along with executable code and produce a new system state as an outcome. This transformation occurs independently of the underlying hardware specifications or the host operating system running on the physical computer.
Virtual machines are used in a wide range of scenarios, including server consolidation, development and testing environments, cloud computing, running legacy applications, and creating sandboxed environments for security testing.
Smart Contracts
Now that we have a concept about VM, let's see how they are combined with blockchains to create what's called smart contracts.
Smart contracts represent a revolutionary approach to agreements and transactions by encapsulating contractual terms and conditions directly into code. When the predefined conditions are met, they can be executed and enforce the terms of the contract without the need for intermediaries.
To make this clearer let's give an example of a smart contract that is used for fundraising a start-up, here's how the process would work:
Create a smart contract on the blockchain, defining key parameters such as the fundraising target, project milestones, and the allocation of investor funds. Additionally, you can stipulate that investors have the option to withdraw their contributions if they are dissatisfied with your project's progress. You can even code in provisions for profit sharing with early investors in the future.
The logic and conditions you've specified are encoded into the smart contract and permanently recorded on the blockchain. This ensures that the terms of the agreement cannot be altered or tampered with by any party, providing a high degree of security and trust.
Investors send their contributions directly to the smart contract. All actions within the contract are transparent and visible on the public blockchain, allowing anyone to inspect the progress and utilization of funds. This transparency builds trust among investors.
As your project progresses and meets the predefined milestones, the smart contract autonomously executes the specified actions. For instance, if a milestone is reached, funds may be released to you. If not, investors can trigger a refund as per the contract's conditions.
To make this process work seamlessly, it's crucial to ensure that every node operator within the blockchain network reaches a consensus on the correct execution of the smart contract. This is where the virtual machine (VM) comes into play.
By utilizing a VM, the blockchain network can guarantee that every user, irrespective of their computer hardware or operating system, will execute the smart contract starting from the same initial state and arrive at the same concluding state. This consistency is fundamental for establishing trust and reliability in smart contract execution within a decentralized blockchain ecosystem.
Creating a Virtual Machine
The VM we'll be creating unfortunately won't be capable of doing something as impressive, as what was just described, since VMs with those capabilities are very complex and take an enormous amount of work and time to build.
We'll be implementing a simple stack-based VM, tailored to interact with the state in our blockchain. The state will be a Map that takes as a key a number and the value will be another number.
To begin create a new file called vm.ts
where we'll establish the set of operation codes (OpCodes) our VM will support. Given that we are constructing a stack-based VM, our initial operations will revolve around managing the stack.
We will introduce two fundamental operations: one for pushing values onto the stack (PUSH) and another for removing values from it (POP).
Next, we will implement arithmetic operations, including addition (ADD), subtraction (SUB), multiplication (MUL), and division (DIV).
Finally, we'll enable interactions with the blockchain's state by defining two operations: one for retrieving a value from the state (GET) and another for storing a value in the blockchain's state (STORE).
We'll also define a new type that will represent the Instructions we can give to the VM.
export enum OPCODES{
PUSH,
POP,
ADD,
SUBTRACT,
MULTIPLY,
DIVIDE,
GET,
STORE
}
export type Instruction =
| { type: OPCODES.PUSH; value: number }
| { type: OPCODES.POP }
| { type: OPCODES.ADD }
| { type: OPCODES.SUBTRACT }
| { type: OPCODES.MULTIPLY }
| { type: OPCODES.DIVIDE }
| { type: OPCODES.GET; key: number }
| { type: OPCODES.STORE; key: number }
Now that we have our operations defined let's implement them in our VM, the only element that we'll have is the stack which is a number array.
We'll create an execute method, that will take as an argument an array of instructions, as previously defined, and a blockchain state, which allows us to read and write data according to the instructions.
Let's now code how the VM will handle the instructions it'll receive. Starting with PUSH
we'll simply add the specified value onto the stack. For POP
operations, we remove the top value from the stack and return it. If the stack is empty, we throw an error.
Arithmetic operations will involve fetching the top two values from the stack, performing the intended arithmetic operation, and then pushing the result back onto the stack, as with POP
if there aren't enough values on the stack we throw an error.
Lastly, for our state operations, starting with GET
operation takes a key as an argument and retrieves the corresponding value from the blockchain state. If the value exists, it is pushed onto the stack, if not an error is thrown. The STORE
operation takes a key and stores the top value from the stack in the blockchain state. If the stack is empty we throw an error.
We also need a protocol for handling errors. When an error is thrown, it signifies that the code provided to the VM is incorrect. In this scenario, considering that the blockchain state is alerted before the error, we must discard all changes made to the state. This is essential to maintain the integrity of the state despite encountering erroneous code.
export class VirtualMachine {
stack: number[] = [];
constructor(){
}
push(value: number) {
this.stack.push(value);
}
pop(): number | undefined {
return this.stack.pop();
}
execute(code: Instruction[], state: Map<number,number> ) {
const originalState = new Map<number,number>(state)
for (const instruction of code) {
switch (instruction.type) {
case OPCODES.PUSH:
this.push(instruction.value);
break;
case OPCODES.POP:
const popped = this.pop();
if (popped !== undefined) {
} else {
state.clear()
for(const [key, value] of originalState.entries()){
state.set(key,value)
}
throw new Error("Stack is empty")
}
break;
case OPCODES.ADD:
const addA = this.pop();
const addB = this.pop();
if (addA !== undefined && addB !== undefined) {
const result = addB + addA;
this.push(result);
} else {
state.clear()
for(const [key, value] of originalState.entries()){
state.set(key,value)
}
throw new Error("Not enough values on the stack for addition")
}
break;
case OPCODES.SUBTRACT:
const subA = this.pop();
const subB = this.pop();
if (subA !== undefined && subB !== undefined) {
const result = subB - subA;
this.push(result);
} else {
state.clear()
for(const [key, value] of originalState.entries()){
state.set(key,value)
}
throw new Error("Not enough values on the stack for subtraction")
}
break;
case OPCODES.MULTIPLY:
const mulA = this.pop();
const mulB = this.pop();
if (mulA !== undefined && mulB !== undefined) {
const result = mulB * mulA;
this.push(result);
} else {
state.clear()
for(const [key, value] of originalState.entries()){
state.set(key,value)
}
throw new Error("Not enough values on the stack for multiplication")
}
break;
case OPCODES.DIVIDE:
const divA = this.pop();
const divB = this.pop();
if (divA !== undefined && divB !== undefined) {
const result = Math.floor(divB / divA);
this.push(result);
} else {
state.clear()
for(const [key, value] of originalState.entries()){
state.set(key,value)
}
throw new Error("Not enough values on the stack for division")
}
break
case OPCODES.GET:
const key = instruction.key
const storedValue = state.get(key)
if(storedValue !== undefined){
this.push(storedValue)
}else{
state.clear()
for(const [key, value] of originalState.entries()){
state.set(key,value)
}
throw new Error("No value stored at that key")
}
break
case OPCODES.STORE:
const stackValue = this.pop()
if (stackValue !== undefined) {
state.set(instruction.key, stackValue)
} else {
state.clear()
for(const [key, value] of originalState.entries()){
state.set(key,value)
}
throw new Error("Stack is empty")
}
break
}
}
}
}
It's now time to test the VM we just built, let's create a new file named vm.test.ts
. The first thing we'll do is write a program for our VM and see if it works as we expect it. The goal of the program will be to add two numbers, multiply the result with a third number, and insert the result of the multiplication in the state under the key 5.
So the first Instructions we'll make will be two PUSH
operations to put two numbers on the stack. Next, we'll perform an ADD
on these two numbers which will remove them from the stack and put the result in the stack. Now we'll again use PUSH
to put a new number on the stack and then use MUL
to remove this new number and the result of the addition from the stack and put the result of the multiplication on the stack. Finally, we'll use the STORE
operation with the key 5 to insert that result into the state.
If everything works correctly no errors we'll be thrown and we'll be able to inspect the state to verify the result is as we expected it.
The next test we'll write is to verify that errors are thrown correctly and the state reverts to before when execution began. For this purpose let's create a program that performs a PUSH
operation and performs STORE
on the pushed value. Then we'll attempt to perform an ADD
, as the stack will be empty an error must be thrown. If everything worked properly then the state alteration we did in the second command must have been reverted.
Lastly, we'll perform a check to ensure that if we try to execute an empty program, the execution will not throw any errors.
import {describe, expect, test} from '@jest/globals';
import {VirtualMachine, Instruction, OPCODES} from "../src/vm.js"
describe("Virtual Machine tests", () => {
test("Virtual machine should correctly execute program and alter state", () => {
//Push 5 onto stack
//Push 7 onto stack
//Remove 5 and 7 from stack add them and push result to stack
//Push 3 onto stack
//Remove result of adition and 3 from stack multiply and push result to stack
//Remove result from multiplication from stack and store in state under key 5
const code: Instruction[] = [
{ type: OPCODES.PUSH, value: 5 },
{ type: OPCODES.PUSH, value: 7 },
{ type: OPCODES.ADD },
{ type: OPCODES.PUSH, value: 3 },
{ type: OPCODES.MULTIPLY },
{ type: OPCODES.STORE, key: 5 },
];
const state = new Map<number,number>()
const vm = new VirtualMachine()
vm.execute(code,state)
expect(vm.stack.length).toBe(0)
expect(state.get(5)).toBe((5+7)*3)
})
test("Virtual machine should throw error if program doesn't execute correctly, and revert state", () => {
const code: Instruction[] = [
{ type: OPCODES.PUSH, value: 7 },
{ type: OPCODES.STORE, key: 5 },
{ type: OPCODES.ADD },
{ type: OPCODES.PUSH, value: 3 },
{ type: OPCODES.MULTIPLY },
{ type: OPCODES.STORE, key: 5 },
];
const state = new Map<number,number>()
const vm = new VirtualMachine()
expect(()=> vm.execute(code,state)).toThrowError(new Error("Not enough values on the stack for addition"))
expect(state.get(5)).toBe(undefined)
})
test("Virtual machine should not throw error on empty instruction array", () => {
const state = new Map<number,number>()
const vm = new VirtualMachine()
vm.execute([],state)
})
})
Integrating the VM in the Blockchain
Since our VM is working correctly, it's time we integrate it into our blockchain. We'll begin by defining how users can send the instructions to be executed in the VM. To do this we'll add a new element to the Transaction object called data
, where the user can put the code they wish for the VM to run.
We need to change the way that the hash is made so that it includes this new field, and therefore the signature will now include the data.
export class Transaction{
from: string
to: string
amount: number
nonce: number
data: Instruction[]
hash: string
signature: string
constructor(from: string, to:string, amount:number, nonce: number,data: Instruction[], hash:string, signature: string){
this.from = from
this.to = to
this.amount = amount
this.nonce = nonce
this.data = data
this.hash = this.calculateHash()
this.signature = signature
}
static initialize(from: string, to:string, amount:number, nonce: number, data: Instruction[]){
const newTx = new Transaction(from, to, amount, nonce, data, "" ,"")
newTx.hash = newTx.calculateHash()
return newTx
}
calculateHash(){
return sha256(this.from +
this.to + this.amount + this.nonce + this.data.toString() )
}
Next, let's move on to the blockchain. We'll begin by introducing a new element into the blockchain called state. As discussed previously this will be a Map that takes as a key a number and value will be another number.
We'll execute the code when using addBlock
in the blockchain. In this method, we check the validity of every transaction inside the block, when doing this we'll also create a new instance of the VM we just created and pass in the data inside the transaction as well as the state of the blockchain.
export class Blockchain{
blocks: Block[]
accounts: Map<string, AccountDetails>
blockDifficulty: string
state: Map<number,number>
constructor( blocks: Block[], accounts:
Map<string, AccountDetails>,blockDifficulty: string, state: Map<number,number>){
this.blocks = blocks
this.accounts = accounts
this.blockDifficulty = blockDifficulty
this.state = state
}
static initialize(blockDifficulty: string, initialBalances?:Map<string, AccountDetails>){
const genesisBlock = Block.initialize(0, [], "")
if (initialBalances){
return new Blockchain( [genesisBlock],initialBalances, blockDifficulty,new Map<number,number>())
}else{
return new Blockchain( [genesisBlock],new Map<string, AccountDetails>(), blockDifficulty, new Map<number,number>())
}
}
....
for(let i = 0; i< block.data.length; i++){
const tx = block.data[i]
const accountFrom = this.accounts.get(tx.from)
const accountTo = this.accounts.get(tx.to)
if(!accountFrom){
throw new Error(`Account from: ${tx.from}
does not have any balance`)
}else if (accountFrom.balance < tx.amount ){
throw new Error(`Account from: ${tx.from}
does not enough balance, current balance:
${accountFrom.balance}, transfer amount: ${tx.amount}`)
}else if(accountFrom.nonce !== tx.nonce){
throw new Error(`Invalid nonce, account current nonce:
${accountFrom.nonce}, tx nonce: ${tx.nonce}`)
}
accountFrom.balance -= tx.amount
accountFrom.nonce += 1
this.accounts.set(tx.from, accountFrom)
if(accountTo){
accountTo.balance += tx.amount
this.accounts.set(tx.to, accountTo)
}else{
this.accounts.set(tx.to,{nonce: 0, balance: tx.amount})
}
const vm = new VirtualMachine()
vm.execute(tx.data,this.state)
}
this.blocks.push(block)
}
We'll now make a test that the execution inside the blockchain is working correctly. We'll initialize a blockchain, and then create a new transaction using the same code that we ran when testing the VM. We'll then add that transaction to a block, mine the block and add it to the blockchain. Lastly, we'll verify that the state was successfully updated.
test('Blockchain state should alter on executing VM code',() =>{
const difficulty = "ffff"
const wallet = Wallet.initialize()
const balances = new Map<string,AccountDetails>()
balances.set(wallet.pubKey, {balance: 100, nonce: 0})
const blockchain = Blockchain.initialize(difficulty,balances)
const code: Instruction[] = [
{ type: OPCODES.PUSH, value: 5 },
{ type: OPCODES.PUSH, value: 7 },
{ type: OPCODES.ADD },
{ type: OPCODES.PUSH, value: 3 },
{ type: OPCODES.MULTIPLY },
{ type: OPCODES.STORE, key: 5 },
];
const tx = Transaction.initialize(wallet.pubKey,'',0,0,code)
tx.sign(wallet)
const block = Block.initialize(1,[tx],blockchain.blocks[0].hash)
block.mineBlock(difficulty)
blockchain.addBlock(block)
expect(blockchain.state.get(5)).toBe(36)
})
Now we just need to perform one last change before we can run the blockchain, which is to update the server. We'll make an upgrade so that we can now insert a transaction with code for the VM inside a block, and transmit that block in the p2p network. We'll create a method called addAndTransmitBlockWithCode
that will load a program from the .env file, similar to what we did with wallets in the last part.
Then we'll create a transaction that will include that code, insert it into a block, mine the block and return it so that we can transmit it.
To run this method, we'll create a new command for users to write in the console. We'll make it so that if a user types v
into the console, the method we just created will be used and the block created will be transmitted on the p2p network.
addAndTransmitBlockWithCode(){
const instructions:Instruction[] = JSON.parse(process.env.CODE)
const tx = Transaction.initialize(this.wallets[0].pubKey, "", 0, 0,instructions)
tx.sign(this.wallets[0])
const height = this.blockchain.blocks.length
const prevHash = this.blockchain.blocks[height-1].hash
const block = Block.initialize(height,[tx],prevHash)
block.mineBlock(this.blockchain.blockDifficulty)
try {
this.blockchain.addBlock(block)
return Buffer.from(JSON.stringify(block))
} catch (error) {
console.log(error)
}
}
....
process.stdin.on('data', async data => {
let stringData = data.toString()
if(stringData.slice(0,1) === 'b'){
console.log('\x1b[36m%s\x1b[0m',"Adding block to chain and transmitting on P2P network");
let array = stringData.split(" ")
console.log('\x1b[36m%s\x1b[0m','Sent Block on P2P network',
await node.services.pubsub.publish(TOPICS.block, this.addAndTransmitBlock(array)))
} else if(stringData.slice(0,1) === 'c'){
console.log('\x1b[32m%s\x1b[0m',"Transmitting chain over p2p network")
console.log('\x1b[32m%s\x1b[0m',"Sent Local Blockchain on P2P network",
await node.services.pubsub.publish(TOPICS.chain, Buffer.from(JSON.stringify(this.blockchain,replacer))))
} else if(stringData.slice(0,1) === 'p'){
console.log('\x1b[33m%s\x1b[0m',"Printing Local Blockchain \n",this.blockchain)
} else if(stringData.slice(0,1) === 'v'){
console.log('\x1b[35m%s\x1b[0m',"Adding block with transaction with VM instructions to chain and transmitting on P2P network\n")
console.log('\x1b[35m%s\x1b[0m',"Sent Block on P2P network \n",
await node.services.pubsub.publish(TOPICS.block, this.addAndTransmitBlockWithCode()))
} else{
console.log(`Invalid command you've typed ${data.toString()}`);
}
});
Running the Blockchain
For this, we don't need to make any alterations to index.ts
from the last part. What we need to do is update our .env file to have a program written into it. To prepare the program you can simply write an array of instructions and pass it through JSON.stringfy()
. As an example, this is what the program that we've been using in our tests looks like when inserted into the .env
file.
CODE ='[{"type":0,"value":5},{"type":0,"value":7},{"type":2},{"type":0,"value":3},{"type":4},{"type":6,"key":5}]'
We're now ready to run the blockchain. As before start two terminals and type npm run dev
on both of them. We should once again see that they've connected. Now type v
in one of them and press enter, we'll see that a block was produced and sent over the network.
If you now type in p
you should see that the state of the blockchain has been updated the same way we've seen in tests. Now on the other terminal also type in p and verify that the block has been included successfully and the state has also been updated in the same way.
So by using a VM, we have achieved deterministic code execution, ensuring that the blockchain's state is altered consistently. This ensures that all network participants can unanimously agree on the same state resulting from the execution of identical code, regardless of their hardware or operating system specifications.
Conclusion
With this, we've completed this series. We began by explaining the concept of a blockchain, and what kind of possibilities it enabled.
We then began coding our blockchain from scratch, using the concepts described in the first part we began coding a blockchain that would addear to the principles described.
Afterward, we introduced a cryptocurrency into the blockchain and made possible secure token exchange through cryptographic transactions, bolstering the functionality of our blockchain.
We then created a P2P network that allows users to connect and share the state of the blockchain. It allowed the transmission of new blocks over the network and let every user keep track of the validity of the blockchain.
Finally, in this part we created a VM and added it to the blockchain, making it possible for users to alter a state by running deterministic code, and letting very user validate that the state transition was valid.
The code for this part can be found here.
This series is comprised of the following posts: