The Upgrade Trap — How Versioned Objects Can Brick Protocols on Sui

Sui lets you upgrade packages. It also lets you make packages immutable. But if your immutable package depends on someone else's upgradeable package, you're one upgrade away from a permanent freeze. This post explains how Sui's Versioned wrapper works, how the object versioning pattern is used in production, and how it creates a contagion effect that forces the entire ecosystem toward upgradeability.

0xTheBlackPanther April 2, 2026 10 min read Sui, Move, Upgrades, Security

Background: How Sui Package Upgrades Work

When you publish a Move package on Sui, it gets an on-chain address — say 0xAAA. Users call your functions at 0xAAA::pool::swap.

Here's the thing: that code at 0xAAA is immutable. It will never change. Ever. So what does "upgrade" mean?

An upgrade publishes a new package at a new address — say 0xBBB. This new package is linked to the original via an UpgradeCap. The key rule: 0xBBB can access and modify objects that were originally declared by 0xAAA.

// Original package 0xAAA::pool::swap ← still exists, code never changes // Upgraded package 0xBBB::pool::swap ← new logic, can modify 0xAAA's objects

Both packages exist on-chain simultaneously. Users can call either one. The old code is never deleted.

This creates an obvious problem: if the upgrade adds a fee to swap, what stops users from just calling the old fee-free version?

The answer: object versioning.

The Versioned Wrapper — Sui's Migration Tool

Sui framework provides a utility called Versioned at sui::versioned. It's a wrapper that stores your data as a dynamic field, keyed by a version number.

// The struct itself public struct Versioned has key, store { id: UID, version: u64, } // What it looks like in practice ┌─────────────────────────┐ │ Versioned │ │ id: UID │ │ version: 1 │ │ │ │ dynamic_field[1] ───────── YourDataV1 { ... } └─────────────────────────┘

It has 5 functions. That's it.

FunctionWhat It Does
createWrap initial data with a version number
load_valueRead the inner data (you specify the expected type)
load_value_mutGet mutable access to the inner data
remove_value_for_upgradeTake data out for migration (returns a hot potato)
upgradePut new data back with a higher version number

The version number is literally the dynamic field key. When you call create(1, my_data, ctx), it does dynamic_field::add(&mut self.id, 1, my_data). When you call load_value(), it does dynamic_field::borrow(&self.id, self.version). Nothing magical.

The Hot Potato Safety Trick

remove_value_for_upgrade returns your data AND a VersionChangeCap:

public struct VersionChangeCap { versioned_id: ID, old_version: u64, }

This struct has no drop, no store, no key. It's a hot potato — once created, it must be consumed by calling upgrade in the same transaction. If you forget, the transaction aborts. You can never leave a Versioned object in an empty half-upgraded state.

The upgrade function validates two things before accepting the new data:

public fun upgrade<T: store>(self: &mut Versioned, new_version: u64, new_value: T, cap: VersionChangeCap) { let VersionChangeCap { versioned_id, old_version } = cap; // Cap must belong to THIS Versioned object (can't swap caps between objects) assert!(versioned_id == object::id(self), EInvalidUpgrade); // New version must be strictly higher (no downgrades, no same-version rewrites) assert!(old_version < new_version, EInvalidUpgrade); dynamic_field::add(&mut self.id, new_version, new_value); self.version = new_version; }

How Protocols Actually Use This

In practice, protocols combine Versioned with version checks in every function. Here's a simplified DEX example:

// ── Version 1: Initial deployment ── const VERSION: u64 = 1; public struct PoolStateV1 has store { reserve_a: Balance<A>, reserve_b: Balance<B>, } public struct Pool has key { id: UID, inner: Versioned, // holds PoolStateV1 } public fun swap(pool: &mut Pool, ...) { assert!(pool.inner.version() == VERSION, EWrongVersion); let state: &mut PoolStateV1 = pool.inner.load_value_mut(); // ... swap logic, no fees ... }

Later, the team deploys an upgrade that adds fees:

// ── Version 2: Upgraded package ── const VERSION: u64 = 2; public struct PoolStateV2 has store { reserve_a: Balance<A>, reserve_b: Balance<B>, fee_bps: u64, // new field } public fun migrate(pool: &mut Pool) { let (old, cap) = pool.inner.remove_value_for_upgrade<PoolStateV1>(); let PoolStateV1 { reserve_a, reserve_b } = old; let new_state = PoolStateV2 { reserve_a, reserve_b, fee_bps: 30 }; pool.inner.upgrade(2, new_state, cap); } public fun swap(pool: &mut Pool, ...) { assert!(pool.inner.version() == VERSION, EWrongVersion); // checks for 2 let state: &mut PoolStateV2 = pool.inner.load_value_mut(); // ... swap logic with fees ... }

Once migrate is called, the pool's version becomes 2. Now:

1. Old package's swap checks version == 1, finds 2, aborts.
2. New package's swap checks version == 2, passes, executes with fees.
3. Even if someone tries the old load_value<PoolStateV1>(), it aborts because the dynamic field key is now 2 and the type is PoolStateV2.

The old package is effectively dead. Every call to it fails. Users are forced onto the new version.

This pattern is used everywhere in production — Sui uses it internally for SuiSystemState and the native Bridge. Most serious DeFi protocols on Sui follow the same pattern.

The Upgrade Trap

Here's where it gets dangerous.

Imagine you're building an aggregator. Your package calls the DEX's swap function. You're security-conscious, so you make your package immutable — no upgrade cap, no admin keys, fully trustless.

// Your aggregator package (immutable, deployed at 0xCCC) public fun route_swap(pool: &mut Pool, ...) { // calls the DEX's swap function at 0xAAA dex::pool::swap(pool, ...); }

Your package was compiled and linked against the DEX at address 0xAAA (version 1). This linkage is baked in at publish time. Your code will always call 0xAAA::pool::swap.

Then the DEX upgrades. They publish 0xBBB, call migrate, and the pool version becomes 2.

// What happens now: User calls 0xCCC::aggregator::route_swap(pool) │ └─→ calls 0xAAA::pool::swap(pool) ← old package │ └─→ assert!(pool.version == 1) ← pool is now version 2 │ └─→ ABORT

Your aggregator is bricked. Every transaction through it fails.

Since your package is immutable, you can't upgrade it to call 0xBBB::pool::swap instead. The funds aren't necessarily stuck (users can interact with the DEX directly), but your protocol is permanently broken.

This isn't theoretical. Any immutable package that calls a versioned-object protocol is one migrate() call away from permanent failure. The more protocols adopt the versioning pattern, the more dangerous it becomes to be immutable.

The Contagion Effect

This creates a cascading force across the entire ecosystem:

DEX (upgradeable, uses versioning) │ ├─→ Aggregator (must be upgradeable, or risk bricking) │ │ │ └─→ Yield Vault (must be upgradeable, or risk bricking) │ │ │ └─→ Strategy Protocol (must be upgradeable, or risk bricking) │ ├─→ Lending Protocol (must be upgradeable, or risk bricking) │ └─→ Your Protocol (must be upgradeable, or risk bricking)

If one foundational protocol is upgradeable with versioned objects, everything built on top is forced to be upgradeable too. And everything built on top of that. The upgradeability spreads like a virus through the dependency tree.

The result: an ecosystem where almost every protocol is upgradeable, which means almost every protocol has an admin key that can change the logic. That's a lot of trust in a lot of teams.

ChoiceUpsideDownside
Make your package immutableFully trustless, no admin riskOne dependency upgrade can brick you forever
Make your package upgradeableCan adapt to dependency changesUsers trust your admin key not to rug

There's no clean middle ground today.

What About Sui's Upgrade Compatibility Rules?

Sui does enforce compatibility on upgrades. When you upgrade a package, the bytecode verifier ensures:

1. You cannot remove any public function.
2. You cannot change any public function signature (params, return types).
3. You cannot remove or change any public struct layout.
4. You can add new functions, new modules, and change function bodies.

So the API contract is enforced at the bytecode level. The old swap function will always exist with the same signature.

But here's the catch: the semantic contract is not enforced. The function body can change completely. And the versioning pattern uses this exact freedom — the function still exists, but now it aborts because the version check fails.

Think of it this way. Sui guarantees the door will always be there and the lock will always accept your key shape. But it doesn't guarantee there isn't a brick wall behind the door. Object versioning is that brick wall.

Can You Protect Yourself?

There's no perfect solution, but there are strategies:

1. Keep Your Package Upgradeable With Strong Governance

Accept the reality that you need upgradeability, but protect it. Use time-locked upgrades, multisig governance, or Sui's upgrade policies (additive-only or dep-only) to limit what an upgrade can do.

2. Pin Dependencies and Monitor

If a dependency protocol publishes an upgrade, you need to know about it and react before they call migrate. This is operational overhead but it's the price of composability.

3. Design For Breakage

If your protocol calls external protocols, don't make those calls the only path. Design fallback mechanisms. Store user funds in a way that allows withdrawal even if an external call path breaks.

4. Lobby Foundational Protocols

The cleanest solution is for base-layer protocols (DEXes, lending, oracles) to either be immutable or provide backwards-compatible upgrade paths that don't break callers. If the DEX's old swap function redirected to the new logic instead of aborting, the whole problem disappears.

Security Researcher Takeaways

If you're auditing Sui Move protocols, here's what to watch for:

Related Posts

Move VM Runtime — How Your Move Code Actually Runs — How the Move VM loads, compiles, and executes bytecode on Sui.

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

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.
Source: sui/crates/sui-framework/packages/sui-framework/sources/versioned.move
Lines: 89 lines of Move
Used by: SuiSystemState, Bridge, and most production DeFi protocols
Risk: Upgradability contagion across the dependency tree
Follow: @thepantherplus