Move VM Runtime — How Your Move Code Actually Runs

You write Move. You publish it. Someone calls your function. But what actually happens between your public fun transfer() and the state changing on-chain? This post walks through the move-vm-runtime crate — the engine that loads, compiles, and executes every line of Move bytecode on Sui.

✍ 0xTheBlackPanther 📅 April 1, 2026 ⏱ 12 min read 🏷 Sui, Move VM, Runtime, Security

What Is move-vm-runtime?

When you write Move code, the compiler turns it into bytecode. That bytecode gets published on-chain as raw bytes. When someone calls your function, something needs to take those bytes, verify them, make them executable, and run them instruction by instruction.

That something is move-vm-runtime. It's the engine inside the Move VM that handles everything from raw bytes to final return values.

Think of it like a car engine. You (the developer) write the route (Move code). The compiler makes it into a GPS file (bytecode). The engine (move-vm-runtime) reads that file, checks it's valid, optimizes the path, and drives — burning gas along the way. The Sui blockchain is the road network.

It lives at sui/external-crates/move/crates/move-vm-runtime/ — 87 Rust files, ~31,000 lines of code, organized into 7 modules.

Where Did It Come From?

Move was created by Facebook for the Libra blockchain (later renamed Diem). When Diem shut down, Mysten Labs forked the entire Move language for Sui. The lineage:

Facebook Libra (2019) | renamed v Diem (2020) github.com/diem/diem | Move split out v Move Language (2022) github.com/move-language/move | forked by Mysten Labs v Sui's external-crates/move/ (current)

So move-vm-runtime started as Diem code, but Sui has heavily rewritten the internals. The value system and native functions still have Diem DNA. The package system, linkage, JIT compilation, caching, and dispatch tables are all Sui-original.

The 7 Modules

Here's every module in move-vm-runtime and what it does:

ModuleRole
validation/Deserialize and verify bytecode (is it safe to run?)
jit/Compile bytecode into an efficient, pointer-resolved format
cache/Store compiled packages so we don't redo work
runtime/Orchestrate loading, caching, and VM creation per transaction
execution/The interpreter — run bytecode instruction by instruction
natives/Rust implementations of built-in functions (vector, hash, BCS)
shared/Safety utilities — checked math, safe indexing, constants

The Full Pipeline: From Bytes to Execution

When a transaction calls a Move function, here's what happens inside the runtime, step by step:

On-chain bytes (published package) | v DESERIALIZE validation/deserialization/ | raw bytes -> CompiledModule v VERIFY validation/verification/ | type safety, borrow checking, ability checks v OPTIMIZE jit/optimization/ | flat bytecode -> basic blocks (CFG) v COMPILE jit/execution/ | resolve all indices to direct pointers v CACHE cache/ | store compiled package for reuse v BUILD VTABLES execution/dispatch_tables.rs | build the lookup directory for this transaction v CREATE VM runtime/ + execution/vm.rs | wire up dispatch tables + natives + gas meter v EXECUTE execution/interpreter/ | run each instruction, charge gas, return values v Return values to Sui adapter

Let's walk through each step.

Step 1: Validation — Is This Bytecode Safe?

Raw bytes arrive from on-chain storage. First, they're deserialized into a CompiledModule — Move's structured representation of a module's functions, types, constants, and bytecode.

Then the bytecode verifier runs. This is the single most important security boundary in the entire VM. It checks:

1. Type safety — every operation gets the right types (no adding a bool to an address)
2. Reference safety — no dangling references, no aliased mutable borrows
3. Resource safety — structs with key/store follow the rules, no double-use
4. Stack balance — every function leaves the stack exactly as declared
5. Linkage — cross-package calls match signatures, no dependency cycles

If verification passes, the bytecode is trusted for the rest of the pipeline. The interpreter does not re-check types at runtime — it trusts the verifier got it right.

Why this matters: If the verifier has a bug — if it approves bytecode that violates type safety or reference rules — the interpreter will happily execute it. A verifier bypass is the highest-severity bug class in Move. It's the reason the verifier is the most heavily tested and formally analyzed component.

Step 2: JIT Compilation — Make It Fast

The name "JIT" is a bit misleading — this isn't Java-style hot-path compilation. It's a two-stage transformation that converts verified bytecode into a format the interpreter can execute efficiently.

Stage 1: Build the Control Flow Graph

Raw bytecode is a flat array of instructions. The optimizer splits it into basic blocks — contiguous runs of instructions between branches. Branch targets become block labels.

// Before: flat array [LdU64(42), StLoc(0), CopyLoc(0), LdU64(10), Lt, BrTrue(8), ...] // After: labeled blocks Block 0: [LdU64(42), StLoc(0), CopyLoc(0), LdU64(10), Lt, BrTrue(Block 2)] Block 1: [...] Block 2: [...]

Stage 2: Resolve Everything to Pointers

In raw bytecode, a function call is an index into a table: "call function #7 in the function handle table." That's slow — every call needs two table lookups.

The JIT compiler resolves these indices:

Call TypeResolutionSpeed
Same-package callCallType::Direct(pointer) — raw pointer to the functionInstant (no lookup)
Cross-package callCallType::Virtual(vtable_key) — resolved at dispatch timeOne hash lookup

Everything gets allocated into an Arena — a bump allocator that puts all of a package's data in contiguous memory. Good for cache performance, and the whole thing gets freed at once when the package is evicted.

Native functions (like vector::push_back) are also resolved here — the compiler looks up the Rust implementation and stores a pointer to it.

Step 3: Caching — Don't Redo Work

The MoveCache stores compiled packages so they survive across transactions. When the same package is needed again, it's served from memory instead of re-verified and re-compiled.

Three things are cached:

1. Compiled packages — keyed by VersionId (the on-chain object ID of that package version)
2. Dispatch tables — keyed by LinkageHash (the exact combination of package versions). If two transactions use the same set of package versions, they share dispatch tables.
3. Interned identifiers — every module name, function name, and type name is stored once and referenced by integer key

Step 4: Building the Dispatch Tables

This is the step where Sui's package upgrade system comes to life.

On Sui, packages get upgraded. Package P might be at version 1 (0xCAFE) or version 2 (0xDEAD). At runtime, code always refers to the original ID (0xCAFE), but the linkage context says which actual version to load.

// The linkage context is a simple map: LinkageContext { 0xCAFE -> 0xDEAD, // "when code says 0xCAFE, load version 0xDEAD" 0xBEEF -> 0xBEEF, // "0xBEEF hasn't been upgraded, use v0" 0x0002 -> 0x0002, // "sui framework, use the original" }

The VMDispatchTables is built from this context. It loads every package in the map, creates a lookup directory of all functions and types, and becomes the phone book the interpreter uses during execution.

Three ID types make this work:

ID TypeWhat It IsExample
OriginalIdThe v0 publication address. Code uses this at runtime.0xCAFE
VersionIdThe on-chain object ID of a specific version.0xDEAD (v2 of 0xCAFE)
DefiningTypeIdThe address where a type was first defined. Used for type identity across upgrades.0xCAFE (even after upgrade)

Step 5: Creating the VM Instance

The MoveRuntime (long-lived, persists across transactions) creates a MoveVM (per-transaction, thrown away after) by wiring together:

1. VMDispatchTables — the function/type lookup directory
2. NativeExtensions — Sui-specific state (object store, events, transfers)
3. VMConfig — limits and settings (stack size, gas schedule)
4. GasMeter — tracks and charges gas for every operation

The entry point is execute_entry_function(). It finds the function in the dispatch tables, verifies that it's marked entry, checks type argument constraints, and hands off to the interpreter.

There's a second entry point: execute_function_bypass_visibility(). This skips the entry check and is used for system calls (epoch changes, etc.). Its doc comment warns: "The ability to deserialize args into arbitrary types is very powerful — it can manufacture signers or Coins from raw bytes." Any bug in how the Sui adapter gates access to this = arbitrary code execution.

Step 6: The Interpreter — Running Bytecode

This is where code actually executes. The interpreter maintains two stacks:

StackWhat It HoldsLimit
Operand StackValues being operated on (like a calculator)1,024 values
Call StackFunction frames (locals + return address)1,024 frames

The interpreter loops through bytecodes in the current basic block. For each instruction, it does the work and charges gas:

// Simplified interpreter loop loop { instruction = current_block[pc] match instruction { LdU64(n) => stack.push(Value::U64(n)), Add => { b = stack.pop(); a = stack.pop(); stack.push(a.add_checked(b)?) }, Call(key) => push new frame, jump to function, Pack(idx) => pop N values, create struct, BorrowField => take struct ref, return field ref, Ret => pop frame, return to caller, ... } gas_meter.charge(instruction)? // OUT_OF_GAS = stop }

How Values Work at Runtime

Every Move value lives as a Rust enum at runtime:

enum Value { Invalid, // moved-from tombstone (linear types!) U8(u8), U16(u16), U32(u32), U64(u64), U128(Box<u128>), U256(Box<U256>), Bool(bool), Address(Box<AccountAddress>), Struct(Struct), // fixed-size array of MemBox<Value> Vec(Vec<MemBox<Value>>), // vector of complex types PrimVec(PrimVec), // vector of primitives (optimized) Reference(Reference), // & or &mut }

References are backed by Rc<RefCell<Value>> — reference-counted with runtime borrow checking. When you take a &mut reference to a struct field, both the reference and the original point to the same underlying data. Writes through the reference propagate back.

When a value is moved (not copied), the original location gets Value::Invalid. That's how Move's "use once" semantics are enforced at runtime.

All integer arithmetic uses checked operations — overflow returns ARITHMETIC_ERROR, never wraps silently.

How Function Calls Work

When the interpreter hits a Call instruction:

1. If CallType::Direct — follow the raw pointer to the function. No lookup needed. This is how same-package calls work.
2. If CallType::Virtual — look up the vtable key in VMDispatchTables. One hash map lookup. This is how cross-package calls work.
3. If the function is native — call the Rust function directly through its Arc<dyn Fn> pointer.
4. Otherwise — push a new frame onto the call stack with the function's locals, and start executing its first basic block.

Native Functions — The Rust Escape Hatch

Some operations can't be expressed in Move bytecode — hashing, serialization, vector internals. These are implemented as native functions in Rust.

Every native has the same signature:

fn native_function( context: &mut NativeContext, // gas, type info, extensions ty_args: Vec<Type>, // generic type arguments args: VecDeque<Value>, // runtime values ) -> PartialVMResult<NativeResult> // cost + return values (or abort)

The standard library natives in move-vm-runtime:

ModuleFunctions
vectorempty, length, push_back, pop_back, borrow, swap, destroy_empty
bcsto_bytes — serialize any value to BCS bytes
hashSHA2-256 and SHA3-256
signerborrow_address — extract address from signer
stringUTF-8 validation
type_nameget — returns the fully-qualified type name as a string

Gas is always charged before the operation, not after. This prevents "do expensive work, then fail to pay" attacks.

Sui adds its own natives on top of these (transfer, object, crypto, events) in a separate crate (sui-move-natives). Those get injected via NativeContextExtensions — a type-erased map that lets Sui plug blockchain-specific state into the VM without the VM knowing about Sui.

The Safety Net — shared/

Every module in the runtime depends on shared/ for safety primitives. These exist because a single unchecked array access or integer overflow in the VM could crash a validator (denial of service).

FileWhat It Prevents
safe_ops.rsSafeIndex — returns Result instead of panicking on out-of-bounds. SafeArithmetic — returns Result on overflow. Used everywhere instead of raw [] and +.
constants.rsHard limits: operand stack (1,024), call stack (1,024), value depth (128), type depth (256), type instantiation nodes (128). Every limit exists to prevent a DoS vector.
vm_pointer.rsVMPointer<T> — wraps raw *const T for arena-allocated data. Marked Send + Sync because the data is immutable after creation. The unsafe contract: the arena must outlive the pointer.
linkage_context.rsLinkageContext — the OriginalId → VersionId routing table. Validated to be injective on construction (no two packages map to the same version).
gas.rsGasMeter trait — the interface Sui implements externally. Every VM operation calls a charge_* method. If it returns Err, execution stops with OUT_OF_GAS.

Putting It All Together

Here's the complete picture. When a Sui transaction calls 0x2::coin::transfer<SUI>(coin, recipient):

1. The Sui adapter builds a LinkageContext mapping every referenced package to its version.
2. MoveRuntime::make_vm() checks the cache for prebuilt dispatch tables matching this exact linkage. Cache miss? Load all packages, verify, JIT-compile, build tables, cache them.
3. A MoveVM instance is created with the dispatch tables + Sui's native extensions.
4. execute_entry_function("0x2", "coin", "transfer", [SUI], [coin, recipient]) is called.
5. The dispatch tables resolve 0x2::coin::transfer to a function pointer. Type arguments are checked against constraints.
6. The interpreter pushes a frame, starts executing bytecode. Each instruction is gas-metered.
7. When the function hits a native call (like transfer::transfer), execution jumps to Rust, which records the transfer in Sui's ObjectRuntime.
8. The function returns. The MoveVM returns the result values to the Sui adapter, which settles the transaction.

What I Learned as a Security Researcher

The verifier is the entire security model. The interpreter trusts verified bytecode completely. It doesn't re-check types, doesn't validate reference safety, doesn't verify abilities. If the verifier says "this is safe," the interpreter runs it. A verifier bypass = arbitrary code execution at the VM level.

References are Rc<RefCell> under the hood. Move's borrow rules are enforced by the verifier at load time, not at runtime. The RefCell will catch violations at runtime with a panic — but that means a verifier bug turns into a validator crash (denial of service) rather than silent corruption. A safety net, but a noisy one.

All arithmetic is checked, no exceptions. Addition, subtraction, multiplication, division, shifts, casts — every operation uses checked_* variants. Overflow returns an error, never wraps. This is a deliberate design choice that eliminates an entire class of vulnerabilities.

The JIT compiler resolves intra-package calls to raw pointers. Same-package function calls skip the dispatch table entirely. This is fast, but the pointer's validity depends on the arena outliving the VMPointer. The lifetime management in cache/ and runtime/ is load-bearing for memory safety.

Dispatch table caching is consensus-critical. Two validators processing the same transaction must produce identical results. If one validator has a stale cache and resolves a function to a different version than another, that's a consensus split. The runtime has defensive checks for this — if the cached linkage doesn't match, it drops the entire cache and rebuilds.

Native functions are the bridge to Sui-specific state. The VM itself knows nothing about objects, transfers, or events. All of that flows through NativeContextExtensions — a type-erased map where Sui injects its runtime state. A downcast failure here panics the validator, so the extension types must always match what the natives expect.

Related Posts

Sui Execution Layer — A Security Researcher's Deep Dive — How Sui processes transactions above the Move VM.

Sui Bella Ciao — Inside the New Move VM — Deep dive into the VM rewrite.

Sui's Cut Package — How Sui Freezes Its Execution Layer — How versioned execution snapshots work.

If anything in this post is inaccurate or outdated, reach out to me on X @thepantherplus and I'll fix it.
Codebase: external-crates/move/crates/move-vm-runtime/ (bella-ciao branch)
Files: 87 Rust files, ~31,000 lines
Modules: validation, jit, cache, runtime, execution, natives, shared
Origin: Forked from Diem, heavily rewritten by Mysten Labs
Follow: @thepantherplus