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.
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.
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.
It has 5 functions. That's it.
| Function | What It Does |
|---|---|
create | Wrap initial data with a version number |
load_value | Read the inner data (you specify the expected type) |
load_value_mut | Get mutable access to the inner data |
remove_value_for_upgrade | Take data out for migration (returns a hot potato) |
upgrade | Put 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:
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:
How Protocols Actually Use This
In practice, protocols combine Versioned with version checks in every function. Here's a simplified DEX example:
Later, the team deploys an upgrade that adds fees:
Once migrate is called, the pool's version becomes 2. Now:
swap checks version == 1, finds 2, aborts.swap checks version == 2, passes, executes with fees.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 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.
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.
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:
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.
| Choice | Upside | Downside |
|---|---|---|
| Make your package immutable | Fully trustless, no admin risk | One dependency upgrade can brick you forever |
| Make your package upgradeable | Can adapt to dependency changes | Users 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:
public function.public function signature (params, return types).public struct layout.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.
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:
- Version checks that use
==instead of>=. Strict equality means every upgrade kills the old package. Greater-than-or-equal allows backwards compatibility. - Immutable packages that depend on versioned-object protocols. This is a ticking time bomb. Flag it. The protocol will break when the dependency upgrades.
- Missing migration guards. Who can call
migrate? If it's permissionless, anyone can trigger the version bump and brick dependent protocols at will. - No fallback withdrawal paths. If the only way to withdraw user funds goes through an external call that can be version-gated, users can get permanently locked out.
- Upgrade cap ownership. Whoever holds the
UpgradeCapcontrols whether and when the trap springs. Audit the governance around this cap carefully.
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.
Lines: 89 lines of Move
Used by: SuiSystemState, Bridge, and most production DeFi protocols
Risk: Upgradability contagion across the dependency tree
Follow: @thepantherplus