Sui Execution Layer — A Security Researcher's Deep Dive
I spent weeks reading every file in sui-execution. Here's how Sui processes transactions under the hood — from raw bytes to final state changes — and what I learned about its security model along the way.
What Is the Execution Layer?
When you submit a transaction to Sui — transfer a coin, mint an NFT, call a Move function — something has to actually run it. That something is the execution layer. It sits between the Sui blockchain (consensus, storage, networking) and the Move VM (the bytecode engine).
The execution layer is NOT the Move VM. Most of the work — coin splits, merges, transfers, gas charging, type checking — happens in Rust inside sui-adapter. The Move VM only gets called when there's an actual MoveCall command that needs to execute Move bytecode.
The Three Modules
The execution layer lives in sui-execution/latest/ and has three main crates:
| Crate | What It Does |
|---|---|
sui-adapter | The transaction processor. Linkage, loading, typing, execution, gas charging. |
sui-move-natives | Rust implementations of native Move functions (transfer, object, crypto, events). |
sui-verifier | Sui-specific bytecode verification at publish time (UID checks, private generics, OTW). |
How a Transaction Flows Through sui-adapter
Every user transaction (a Programmable Transaction Block, or PTB) goes through a strict pipeline before any code runs. Here's the full flow:
Stage 1: Linkage
Sui packages get upgraded. When you call a function, the chain needs to figure out which exact version of every dependency to use. The linkage stage scans the transaction, finds every package referenced, and resolves version conflicts.
Two constraint types: exact (must be this version) and at_least (this version or newer). If a transaction needs the same package at two different exact versions, it fails before any code runs.
The result is a simple map: original_package_id → version_to_use. This gets passed to the Move VM so it loads the right code.
Stage 2: Loading
Takes the raw transaction bytes and converts them into structured Rust types. For each input object, it reads the object from storage and resolves its Move type. For each MoveCall, it looks up the function in the package bytecode and loads its full signature (parameter types, return types, visibility).
Pure bytes (numbers, addresses) stay as raw bytes at this stage — their type isn't known yet until a command uses them.
Stage 3: Typing
The type checker. It walks every command and asks: "do all the types match up?" If you pass a Coin<SUI> to a function expecting Coin<USDC>, it dies here.
It also decides how each value is used:
| Actual Type | Expected Type | Action |
|---|---|---|
T | T | Move (if no copy) or Copy |
T | &T | Auto-borrow |
T | &mut T | Auto-borrow mutable |
&mut T | &T | Auto-freeze (downgrade) |
&T | T | Auto-dereference (read) |
Pure inputs get their type here too. The first command that uses a pure input as u64 gives it that type.
Stage 4: Verification (5 Passes)
Before execution, the typed transaction goes through 5 safety checks:
Coin (no drop ability). The last copy of a value gets optimized into a move.TransferObjects.public and entry functions can be called from PTBs. No reference return types. Private generics enforced (e.g., transfer::transfer<T> requires T to be defined in the calling module).drop or store (hot potatoes) taint their entire "clique" (group of values used together). Tainted values can't be passed to non-public entry functions. This prevents flash-loan-style privilege escalation.Stage 5: Metering
Gas is charged at three checkpoints before execution even starts:
Stage 6: Execution
The interpreter loops through each PTB command and delegates to Context:
| Command | What Happens |
|---|---|
MoveCall | Context creates/reuses a MoveVM for the right linkage, calls the function, collects results |
TransferObjects | Records transfer in ObjectRuntime (pure Rust, no VM) |
SplitCoins | Directly manipulates coin balance in memory (no VM) |
MergeCoins | Destroys source coins, adds balance to target (no VM) |
Publish | Verifies bytecode, pushes package to store, runs init() |
Upgrade | Verifies bytecode + compatibility check against previous version |
MakeMoveVec | Packs values into a vector (no VM) |
Values flow between commands through numbered slots. Command 0 produces Result(0,0), command 1 can reference it. Context manages these slots with move/copy/borrow semantics — same rules as Move itself.
Stage 7: Settlement
After all commands execute:
Package Caching: Three Layers
When the VM needs a package (e.g., the sui framework), it doesn't hit disk every time. Three cache layers, fastest to slowest:
| Layer | Lifetime | What It Holds |
|---|---|---|
TransactionPackageStore | Milliseconds (one transaction) | Packages published in the current transaction |
MoveRuntime cache | ~24 hours (one epoch) | All packages loaded since epoch start |
| RocksDB | Forever | Every package ever published |
The first transaction of an epoch is slower (cold cache). After that, frequently used packages like 0x2 (sui framework) are served from memory.
Execution Modes
Not all transactions are equal. Four security levels control what's allowed:
| Mode | Arbitrary Calls | Arbitrary Values | Conservation Check | Used For |
|---|---|---|---|---|
| Normal | No | No | Yes | User transactions |
| Genesis | Yes | Yes | No | Chain bootstrap |
| System | Yes | Yes | Yes | Epoch changes, clock |
| DevInspect | Optional | Optional | Optional | Dry-run simulation |
The Native Functions (sui-move-natives)
When Move code calls transfer::transfer(), it's not running Move bytecode — it crosses into Rust. The sui-move-natives crate implements every native function.
Core Operations
| Module | What It Does |
|---|---|
transfer.rs | Transfer, freeze, share, receive objects |
object.rs | Create and delete object UIDs |
dynamic_field.rs | Child object CRUD (add, borrow, remove, has) |
event.rs | Emit events for dApps to read |
tx_context.rs | Sender, epoch, gas info, ID generation |
Crypto Primitives
14 crypto files covering every scheme Move contracts might need:
| File | Scheme | Use Case |
|---|---|---|
ed25519.rs | Ed25519 | Sui's default signature scheme |
ecdsa_k1.rs | secp256k1 | Ethereum-compatible signatures, cross-chain |
ecdsa_r1.rs | secp256r1 (P-256) | Passkeys, hardware keys, zkLogin |
bls12381.rs | BLS12-381 | Aggregate signatures, threshold crypto |
groth16.rs | Groth16 ZK-SNARKs | Zero-knowledge proof verification (zkLogin) |
poseidon.rs | Poseidon hash | ZK-friendly hashing (efficient in ZK circuits) |
group_ops.rs | BLS12-381 + Ristretto255 | Low-level curve arithmetic for custom crypto |
zklogin.rs | zkLogin verification | Verify Google/Apple OAuth identity on-chain |
ecvrf.rs | ECVRF | Verifiable random functions |
vdf.rs | Wesolowski VDF | Verifiable delay for random beacon |
hash.rs | Keccak256 + Blake2b256 | General-purpose hashing |
hmac.rs | HMAC-SHA3-256 | Message authentication |
nitro_attestation.rs | AWS Nitro Enclave | Confidential computing verification |
Every crypto native follows the same pattern: charge gas first (proportional to input size), parse inputs, call fastcrypto in Rust, return the result. Invalid inputs return false or error codes — never panic.
The ObjectRuntime
The most critical piece. Every object::new(), object::delete(), transfer::transfer(), and event emission lands in the ObjectRuntime. It's the in-memory ledger that tracks all state changes during a transaction. At the end, it produces RuntimeResults which become the final TransactionEffects.
The Sui Verifier (sui-verifier)
When you publish a Move package, 6 Sui-specific verification passes run on your bytecode:
key must have id: UID as first field. Enums can't have key.move_to, move_from, borrow_global. Sui uses the object model, not global storage.object::new(). Can't forge, reuse, or leak UIDs. Uses abstract interpretation to track UID flow through bytecode.transfer::transfer<T> requires T to be defined in the calling module. Use public_transfer for types with store.init must be private, max 2 params, last is TxContext. Entry functions can't take &mut Clock or &mut Random.drop ability, one bool field, never manually instantiated.All passes are time-budgeted via a meter. If verification takes too long (pathologically complex bytecode), the module is rejected with PROGRAM_TOO_COMPLEX.
What I Learned as a Security Researcher
After weeks in this codebase, here are the non-obvious things I took away:
Move is the interface, Rust is the engine. Most people think the Move VM runs everything. It doesn't. Coin operations, transfers, gas, type checking, verification — all Rust. The VM is one tool the adapter calls when needed.
The typing pipeline is where the real security lives. Five verification passes before any code runs. Two independent borrow checkers. Quadratic gas for references. Hot potato taint analysis. This is more verification than most L1s do during actual execution.
Versioned execution is real engineering. Sui doesn't update its VM — it freezes the old one and ships a new one next to it. Every old transaction replays with the exact code that originally ran it. A custom Rust CLI tool (cut) automates the snapshot-and-rewire process.
Gas is charged before work happens. Three metering checkpoints before execution. Per-instruction metering during execution. Conservation checks after. The gas model is designed so attackers can't get meaningful free compute.
debug_assert-only invariants are real attack surface. Several critical checks in ObjectRuntime::finish() use debug_assert! which are no-ops in release builds. If an invariant is violated in production, execution proceeds silently. Worth auditing.
The deny list only checks top-level coin types. If you wrap a regulated coin inside another struct, the deny list doesn't see it. This is a design limitation, not necessarily a bug — but protocol builders relying on the deny list should be aware.
Related Posts
Sui Bella Ciao — Inside the New Move VM — Deep dive into the VM rewrite itself.
Sui's Cut Package — How Sui Freezes Its Execution Layer — How versioned execution snapshots work.
Crates Covered: sui-adapter, sui-move-natives, sui-verifier
Key Directories: static_programmable_transactions/, crypto/, object_runtime/
Follow: @thepantherplus