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.

✍ 0xTheBlackPanther 📅 March 30, 2026 ⏱ 15 min read 🏷 Sui, Execution, Security, Move

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.

Think of it like a restaurant: The Move VM is the oven. The execution layer is the entire kitchen — the chef reads the order, preps the ingredients, uses the oven when needed, plates the food, and handles the bill. The oven is one tool, not the whole operation.

The Three Modules

The execution layer lives in sui-execution/latest/ and has three main crates:

CrateWhat It Does
sui-adapterThe transaction processor. Linkage, loading, typing, execution, gas charging.
sui-move-nativesRust implementations of native Move functions (transfer, object, crypto, events).
sui-verifierSui-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:

Raw Transaction | v execution_engine.rs -- entry point, routes by tx type | v (for PTBs) LINKAGE -- figure out which package versions to use | v LOADING -- parse raw inputs, resolve functions from bytecode | v TYPING -- type-check everything, infer pure input types | v VERIFICATION -- 5 safety passes (memory, drop, inputs, functions, hot potato) | v METERING -- charge gas for complexity before execution | v EXECUTION -- run each command (interpreter + context + MoveVM) | v SETTLEMENT -- serialize objects, refund gas, conservation checks | v TransactionEffects -- the final output

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 TypeExpected TypeAction
TTMove (if no copy) or Copy
T&TAuto-borrow
T&mut TAuto-borrow mutable
&mut T&TAuto-freeze (downgrade)
&TTAuto-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:

1. Memory Safety — No dangling references, no use-after-move, no conflicting borrows. Uses a borrow graph to track reference lifetimes across the entire PTB.
2. Drop Safety — Every valuable object must be explicitly used. You can't silently discard a Coin (no drop ability). The last copy of a value gets optimized into a move.
3. Input Arguments — Pure bytes must deserialize correctly to their inferred type. Objects must be used according to their ownership (immutable objects can't be mutated). Gas coin can't be consumed except in TransferObjects.
4. Move Functions — Only 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).
5. Hot Potato Taint — Values without 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.
Double borrow checker: Sui built two independent memory safety implementations using completely different algorithms (regex borrow graph vs path-based sets). Both run on every transaction. If they disagree, the transaction is killed immediately. Paranoia as a feature.

Stage 5: Metering

Gas is charged at three checkpoints before execution even starts:

1. Pre-translation — Counts raw inputs, bytes, and commands. Rejects obviously oversized transactions immediately.
2. Post-loading — Charges for type complexity (number of type nodes) and linkage size (number of packages linked).
3. Post-typing — Charges for type references with a quadratic cost. 10 references costs 55 units, not 10. This prevents abuse through reference complexity.

Stage 6: Execution

The interpreter loops through each PTB command and delegates to Context:

CommandWhat Happens
MoveCallContext creates/reuses a MoveVM for the right linkage, calls the function, collects results
TransferObjectsRecords transfer in ObjectRuntime (pure Rust, no VM)
SplitCoinsDirectly manipulates coin balance in memory (no VM)
MergeCoinsDestroys source coins, adds balance to target (no VM)
PublishVerifies bytecode, pushes package to store, runs init()
UpgradeVerifies bytecode + compatibility check against previous version
MakeMoveVecPacks 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:

1. Remaining mutable objects are transferred back to their owners
2. All modified objects are serialized to bytes
3. Unused gas is refunded
4. SUI conservation check — total SUI in must equal total SUI out (minus gas burned). If this fails, the transaction is rolled back and retried. If it fails again, the validator panics — creating or destroying SUI is worse than halting.

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:

LayerLifetimeWhat It Holds
TransactionPackageStoreMilliseconds (one transaction)Packages published in the current transaction
MoveRuntime cache~24 hours (one epoch)All packages loaded since epoch start
RocksDBForeverEvery 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:

ModeArbitrary CallsArbitrary ValuesConservation CheckUsed For
NormalNoNoYesUser transactions
GenesisYesYesNoChain bootstrap
SystemYesYesYesEpoch changes, clock
DevInspectOptionalOptionalOptionalDry-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

ModuleWhat It Does
transfer.rsTransfer, freeze, share, receive objects
object.rsCreate and delete object UIDs
dynamic_field.rsChild object CRUD (add, borrow, remove, has)
event.rsEmit events for dApps to read
tx_context.rsSender, epoch, gas info, ID generation

Crypto Primitives

14 crypto files covering every scheme Move contracts might need:

FileSchemeUse Case
ed25519.rsEd25519Sui's default signature scheme
ecdsa_k1.rssecp256k1Ethereum-compatible signatures, cross-chain
ecdsa_r1.rssecp256r1 (P-256)Passkeys, hardware keys, zkLogin
bls12381.rsBLS12-381Aggregate signatures, threshold crypto
groth16.rsGroth16 ZK-SNARKsZero-knowledge proof verification (zkLogin)
poseidon.rsPoseidon hashZK-friendly hashing (efficient in ZK circuits)
group_ops.rsBLS12-381 + Ristretto255Low-level curve arithmetic for custom crypto
zklogin.rszkLogin verificationVerify Google/Apple OAuth identity on-chain
ecvrf.rsECVRFVerifiable random functions
vdf.rsWesolowski VDFVerifiable delay for random beacon
hash.rsKeccak256 + Blake2b256General-purpose hashing
hmac.rsHMAC-SHA3-256Message authentication
nitro_attestation.rsAWS Nitro EnclaveConfidential 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:

1. struct_with_key — Structs with key must have id: UID as first field. Enums can't have key.
2. global_storage_access — Blocks move_to, move_from, borrow_global. Sui uses the object model, not global storage.
3. id_leak — UIDs must come from object::new(). Can't forge, reuse, or leak UIDs. Uses abstract interpretation to track UID flow through bytecode.
4. private_genericstransfer::transfer<T> requires T to be defined in the calling module. Use public_transfer for types with store.
5. entry_pointsinit must be private, max 2 params, last is TxContext. Entry functions can't take &mut Clock or &mut Random.
6. one_time_witness — OTW type must be MODULE_NAME in caps, only 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.

If anything in this post is inaccurate or outdated, reach out to me on X @thepantherplus and I'll fix it.
Codebase: sui-execution/latest/ (bella-ciao branch)
Crates Covered: sui-adapter, sui-move-natives, sui-verifier
Key Directories: static_programmable_transactions/, crypto/, object_runtime/
Follow: @thepantherplus