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.
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.
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:
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:
| Module | Role |
|---|---|
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:
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:
key/store follow the rules, no double-useIf 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.
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.
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 Type | Resolution | Speed |
|---|---|---|
| Same-package call | CallType::Direct(pointer) — raw pointer to the function | Instant (no lookup) |
| Cross-package call | CallType::Virtual(vtable_key) — resolved at dispatch time | One 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:
VersionId (the on-chain object ID of that package version)LinkageHash (the exact combination of package versions). If two transactions use the same set of package versions, they share dispatch tables.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 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 Type | What It Is | Example |
|---|---|---|
OriginalId | The v0 publication address. Code uses this at runtime. | 0xCAFE |
VersionId | The on-chain object ID of a specific version. | 0xDEAD (v2 of 0xCAFE) |
DefiningTypeId | The 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:
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.
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:
| Stack | What It Holds | Limit |
|---|---|---|
| Operand Stack | Values being operated on (like a calculator) | 1,024 values |
| Call Stack | Function 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:
How Values Work at Runtime
Every Move value lives as a Rust enum at runtime:
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:
CallType::Direct — follow the raw pointer to the function. No lookup needed. This is how same-package calls work.CallType::Virtual — look up the vtable key in VMDispatchTables. One hash map lookup. This is how cross-package calls work.native — call the Rust function directly through its Arc<dyn Fn> pointer.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:
The standard library natives in move-vm-runtime:
| Module | Functions |
|---|---|
vector | empty, length, push_back, pop_back, borrow, swap, destroy_empty |
bcs | to_bytes — serialize any value to BCS bytes |
hash | SHA2-256 and SHA3-256 |
signer | borrow_address — extract address from signer |
string | UTF-8 validation |
type_name | get — 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).
| File | What It Prevents |
|---|---|
safe_ops.rs | SafeIndex — returns Result instead of panicking on out-of-bounds. SafeArithmetic — returns Result on overflow. Used everywhere instead of raw [] and +. |
constants.rs | Hard 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.rs | VMPointer<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.rs | LinkageContext — the OriginalId → VersionId routing table. Validated to be injective on construction (no two packages map to the same version). |
gas.rs | GasMeter 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):
LinkageContext mapping every referenced package to its version.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.MoveVM instance is created with the dispatch tables + Sui's native extensions.execute_entry_function("0x2", "coin", "transfer", [SUI], [coin, recipient]) is called.0x2::coin::transfer to a function pointer. Type arguments are checked against constraints.transfer::transfer), execution jumps to Rust, which records the transfer in Sui's ObjectRuntime.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.
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