blockchaindev
☰ Lessons

Build a blockchain in Rust · Lesson 07 of 07

A minimal VM and intent-based execution

TL;DR — A blockchain VM is a deterministic stack machine that charges gas per operation so computation is bounded and paid for. We build a complete one in Rust (push/add/mul/gas-metered), test a real program, then contrast imperative bytecode with the intent model: sign what you want, let the runtime compose how.

What a blockchain VM actually is

A blockchain virtual machine is a small, fully deterministic computer that every node runs identically. Determinism is non-negotiable: if two nodes execute the same program against the same state and get different answers, consensus breaks. So a chain VM forbids anything nondeterministic — no wall-clock time, no random numbers, no floating point with platform-dependent rounding, no uncontrolled memory growth.

It also has a second hard requirement that ordinary VMs don’t: execution must be paid for and bounded. A user submitting code to a public network could write an infinite loop. The VM defends itself with gas — every instruction costs a fixed amount, the transaction carries a gas budget, and when the budget hits zero, execution halts. This is exactly how the EVM works: it’s a stack machine of bounded depth where “gas measures the computational effort required for operations.”

We’ll build a tiny but complete stack VM with gas metering, run a real program through it under test, and then step back to contrast this imperative model with the declarative intent model.

The stack machine model

A stack machine has no named registers. Instructions push values onto an operand stack and pop values off it. ADD doesn’t take arguments — it pops the top two values, adds them, and pushes the result. This is why bytecode is compact and easy to verify: there’s exactly one place data lives.

Our instruction set:

OpEffectStack before → after
Push(n)put a literal on the stack[..] → [.., n]
Addpop b, pop a, push a+b[.., a, b] → [.., a+b]
Mulpop b, pop a, push a*b[.., a, b] → [.., a*b]
Subpop b, pop a, push a−b[.., a, b] → [.., a−b]
Dupduplicate the top value[.., a] → [.., a, a]
Popdiscard the top value[.., a] → [..]
Haltstop execution, return top

Defining instructions and errors

We start with the data model. Instructions are an enum. Failures are an enum too — and crucially, “out of gas” and “stack underflow” are normal, expected outcomes of running untrusted code, not panics. A blockchain VM must never panic on bad input; it returns a typed error and the transaction reverts.

We use checked arithmetic (checked_add, etc.) so a malicious program can’t trigger an overflow panic or wrap silently — overflow becomes a clean VmError::Overflow.

vm.rs
#[derive(Clone, Debug, PartialEq)]
pub enum Instr {
    Push(i64),
    Add,
    Mul,
    Sub,
    Dup,
    Pop,
    Halt,
}

#[derive(Clone, Debug, PartialEq)]
pub enum VmError {
    OutOfGas,
    StackUnderflow,
    Overflow,
    /// Program counter ran off the end without a Halt.
    NoHalt,
}

Gas accounting

Gas is the spine of the VM. Each opcode has a cost; before executing an instruction we charge it. If the remaining gas can’t cover the cost, we stop with OutOfGas before doing the work — you don’t get to run an operation you can’t pay for. We model costs so that more expensive operations (multiplication) cost more than cheap ones (pop), mirroring real fee schedules where opcodes are priced by computational weight.

vm.rs
impl Instr {
    /// Fixed gas cost per opcode. Heavier ops cost more.
    fn gas_cost(&self) -> u64 {
        match self {
            Instr::Push(_) => 2,
            Instr::Add | Instr::Sub => 3,
            Instr::Mul => 5,
            Instr::Dup => 2,
            Instr::Pop => 1,
            Instr::Halt => 0,
        }
    }
}

The interpreter

Now the core. The VM owns an operand stack, a gas budget, and a program counter pc. The run method loops: fetch the instruction at pc, charge its gas (bailing with OutOfGas if too poor), execute it, advance pc. Halt returns the top of the stack as the program’s result.

Two helpers keep the body clean: charge does the checked gas subtraction, and pop does a checked stack pop that surfaces StackUnderflow instead of panicking on an empty Vec. Every arithmetic op uses checked math and maps overflow to VmError::Overflow.

vm.rs
pub struct Vm {
    stack: Vec<i64>,
    gas: u64,
}

pub struct ExecResult {
    pub result: i64,
    pub gas_used: u64,
}

impl Vm {
    pub fn new(gas_limit: u64) -> Self {
        Vm { stack: Vec::new(), gas: gas_limit }
    }

    /// Deduct gas or fail. Charged BEFORE the op runs.
    fn charge(&mut self, cost: u64) -> Result<(), VmError> {
        self.gas = self.gas.checked_sub(cost).ok_or(VmError::OutOfGas)?;
        Ok(())
    }

    /// Pop with underflow protection — never panics on empty stack.
    fn pop(&mut self) -> Result<i64, VmError> {
        self.stack.pop().ok_or(VmError::StackUnderflow)
    }

    pub fn run(&mut self, program: &[Instr], gas_limit: u64) -> Result<ExecResult, VmError> {
        let mut pc = 0usize;
        while pc < program.len() {
            let instr = &program[pc];
            self.charge(instr.gas_cost())?;

            match instr {
                Instr::Push(n) => self.stack.push(*n),
                Instr::Add => {
                    let (b, a) = (self.pop()?, self.pop()?);
                    self.stack.push(a.checked_add(b).ok_or(VmError::Overflow)?);
                }
                Instr::Sub => {
                    let (b, a) = (self.pop()?, self.pop()?);
                    self.stack.push(a.checked_sub(b).ok_or(VmError::Overflow)?);
                }
                Instr::Mul => {
                    let (b, a) = (self.pop()?, self.pop()?);
                    self.stack.push(a.checked_mul(b).ok_or(VmError::Overflow)?);
                }
                Instr::Dup => {
                    let top = *self.stack.last().ok_or(VmError::StackUnderflow)?;
                    self.stack.push(top);
                }
                Instr::Pop => { self.pop()?; }
                Instr::Halt => {
                    let result = *self.stack.last().ok_or(VmError::StackUnderflow)?;
                    return Ok(ExecResult { result, gas_used: gas_limit - self.gas });
                }
            }
            pc += 1;
        }
        Err(VmError::NoHalt)
    }
}

Note the order of pops in binary ops: the second operand was pushed last, so it’s on top — we pop b first, then a. Getting this backwards silently breaks Sub, which is why it’s worth a test.

A program, end to end

Let’s compute (3 + 4) * 2 and verify both the result and the gas accounting. The program is just a Vec<Instr>. We can hand-trace the gas to know the expected total:

  • Push(3) = 2, Push(4) = 2, Add = 3 → stack [7], gas 7
  • Push(2) = 2, Mul = 5 → stack [14], gas 14
  • Halt = 0

Total: 14 gas, result 14. The test asserts exactly that, plus the underflow and out-of-gas paths to prove the VM fails safely rather than panicking.

vm.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn computes_3_plus_4_times_2_with_gas() {
        let program = vec![
            Instr::Push(3),
            Instr::Push(4),
            Instr::Add,        // -> 7
            Instr::Push(2),
            Instr::Mul,        // -> 14
            Instr::Halt,
        ];
        let gas_limit = 100;
        let mut vm = Vm::new(gas_limit);
        let out = vm.run(&program, gas_limit).expect("program should succeed");

        assert_eq!(out.result, 14);
        assert_eq!(out.gas_used, 14); // 2+2+3+2+5+0
    }

    #[test]
    fn halts_when_gas_runs_out() {
        // Only enough gas for the first Push (cost 2), not the second.
        let program = vec![Instr::Push(1), Instr::Push(2), Instr::Add, Instr::Halt];
        let mut vm = Vm::new(3);
        assert_eq!(vm.run(&program, 3), Err(VmError::OutOfGas));
    }

    #[test]
    fn underflow_is_an_error_not_a_panic() {
        // Add with an empty stack must fail cleanly.
        let program = vec![Instr::Add, Instr::Halt];
        let mut vm = Vm::new(100);
        assert_eq!(vm.run(&program, 100), Err(VmError::StackUnderflow));
    }
}

Run it with cargo test. All three pass. You now have a real, gas-metered, panic-safe stack VM in well under 150 lines — the same shape, scaled down, as the EVM.

The imperative model and its cost

What we just built is the imperative bytecode model: the user (or their compiler) specifies exactly which operations run, in what order. Push, Push, Add, Push, Mul, Halt is a precise recipe. The VM is a faithful, dumb executor — it does precisely what the bytecode says, step by step, charging gas as it goes.

This model is powerful and auditable, but it pushes a lot of work onto the user. To do anything real — swap a token, then stake the proceeds, then register a name — you must encode the entire sequence, get the ordering right, handle intermediate failures, and pay gas for every step you spelled out. The user is responsible for the how.

The declarative / intent model

The intent model inverts this. Instead of signing a precise list of operations, the user signs a statement of the desired outcome — an intent — and the runtime is responsible for composing the operations that achieve it.

Conceptually:

  • Imperative: “Push 3, push 4, add, push 2, multiply.” (You specify the steps.)
  • Intent: “I want the result of (3+4)*2, and I authorize spending up to N gas to get it.” (You specify the goal; the runtime plans the steps.)

In a blockchain setting this is more than syntactic sugar. A single signed intent — “swap 100 of token A for at least 95 of token B, then stake the output” — can be composed by the runtime into many underlying operations, executed atomically (all-or-nothing), with the user signing exactly once. The benefits:

  • One signature, many operations. The user authorizes an outcome, not each step, which is safer and simpler to reason about.
  • Atomicity and composition move into the runtime. The “did the swap succeed before I staked?” sequencing becomes the runtime’s job, not the user’s.
  • The runtime can optimize the path. As long as the signed outcome and limits are honored, how it’s achieved can be chosen by the execution engine.

This model is gaining traction across the ecosystem; some chains — Vexidus, for example — build execution around an intent-based model where one signed intent composes many operations. The conceptual contract is the same everywhere: the user signs what they want plus their limits (max gas, minimum received, deadline), and the runtime decides how, within those bounds.

The crucial point for a VM author: intents don’t replace the gas-metered executor you built — they sit above it. Under the hood, a composed intent still resolves into bounded, deterministic, gas-charged operations running on a machine exactly like our Vm. The intent layer is a planning and authorization front-end; the stack machine with its gas meter is still the engine doing the work.

Takeaways

  • A blockchain VM is a deterministic machine where every op is gas-priced so computation is bounded and paid for.
  • Build it panic-safe: out-of-gas and stack-underflow are typed errors, and arithmetic is checked. Untrusted code must fail cleanly, never crash the node.
  • Charge gas before executing an op, and return gas_used so callers can bill correctly.
  • The imperative model has the user specify every step (the how); the intent model has the user sign an outcome (the what) and lets the runtime compose the steps.
  • Intents are a layer on top of a gas-metered executor, not a replacement for it — the stack machine is still underneath.

That completes the Rust track — you’ve built primitives, hashing, blocks, a mempool, proof of stake, P2P gossip, and a VM. From here, the consensus, zero-knowledge, and VM tracks go deeper on each layer.

Sources