Sui Smart Contract Upgrades: From Version Control to Migration
Smart contract upgrades are a critical aspect of blockchain development, allowing developers to fix bugs, add features, and improve functionality without losing existing state or forcing users to migrate to entirely new contracts. Sui's approach to upgrades is particularly elegant, offering built-in upgrade capabilities that maintain backward compatibility while enabling seamless evolution of your applications.
In this comprehensive guide, we'll walk through implementing upgradeable smart contracts on Sui using the Move programming language, covering everything from initial design to production deployment.
Understanding Sui's Upgrade Architecture
Sui's upgrade system operates on three key principles:
UpgradeCap Authority: Every published package automatically gets an
UpgradeCapobject that controls upgrade permissionsLayout Compatibility: New versions must maintain compatibility with existing object structures
Migration Functions: Developers implement functions to update existing objects to work with new code
Unlike other blockchains where you might deploy entirely separate contracts, Sui upgrades create linked versions of the same logical contract, preserving object relationships and user experience.
Building an Upgradeable Token Contract
Let's build a practical example: a token contract that starts with basic admin functionality and evolves to support more administrators through upgrades.
Version 1: Foundation with Versioning
Our initial contract establishes the foundation with built-in version tracking:
module token::token {
use std::ascii;
use std::option;
use sui::coin::{Self, Coin, TreasuryCap, CoinMetadata};
use sui::url;
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
use sui::vec_set::{Self, VecSet};
// Version constants - critical for upgrade tracking
const CURRENT_VERSION: u64 = 1;
const MAX_ADMINS_V1: u32 = 2;
const MAX_ADMINS_V2: u32 = 4; // Prepared for future upgrade
// Error codes
const E_NOT_OWNER_ADMIN: u64 = 1;
const E_WRONG_VERSION: u64 = 2;
const E_ALREADY_MIGRATED: u64 = 3;
// One-Time Witness for token creation
public struct TOKEN has drop {}
// Versioned shared objects
public struct AdminRegistry has key {
id: UID,
version: u64, // Version tracking
owner_address: address,
admin_address: VecSet<address>,
max_admins: u32, // Upgradeable limit
}
public struct Treasury has key {
id: UID,
version: u64, // Version tracking
treasury_wal_address: address
}
// Owned objects don't need versioning
public struct OwnerCap has key, store {
id: UID,
}
// Witness pattern for access control
public struct Witness has drop {}
The key design decisions here are:
Version fields in all shared objects for upgrade tracking
Forward-looking constants (MAX_ADMINS_V2) for planned upgrades
Consistent error handling with defined error codes
Separation of concerns between owned and shared objects
Initialization with Upgrade Awareness
fun init(witness: TOKEN, ctx: &mut TxContext) {
let icon_url = ascii::string(b"https://example.com/token-icon.png");
let (mut treasury_cap, metadata) = coin::create_currency<TOKEN>(
witness,
9,
b"TOKEN",
b"Upgradeable Token",
b"A token contract demonstrating Sui upgrades",
option::some(url::new_unsafe(icon_url)),
ctx
);
let sender = tx_context::sender(ctx);
let treas_address = @0xee571f26d4a51d32601e318dbaacd7f1250ed20915582ae0037d8b02e562fe78;
// Initialize all shared objects with version 1
let treasury = Treasury {
id: object::new(ctx),
version: CURRENT_VERSION,
treasury_wal_address: treas_address
};
let owner_cap = OwnerCap {
id: object::new(ctx),
};
let admin_registry = AdminRegistry {
id: object::new(ctx),
version: CURRENT_VERSION,
owner_address: sender,
admin_address: vec_set::empty<address>(),
max_admins: MAX_ADMINS_V1, // Start with 2 admin limit
};
// Standard token setup
coin::mint_and_transfer(&mut treasury_cap, TOKEN_SUPPLY, treas_address, ctx);
transfer::public_transfer(treasury_cap, sender);
transfer::public_freeze_object(metadata);
// Object distribution
transfer::share_object(treasury);
transfer::public_transfer(owner_cap, sender);
transfer::share_object(admin_registry);
}
Migration Function: The Upgrade Bridge
The migration function is where the magic happens - it's responsible for updating existing objects to work with new code
entry fun migrate(
admin_registry: &mut AdminRegistry,
treasury: &mut Treasury,
ctx: &TxContext
) {
let sender = tx_context::sender(ctx);
// Security: Only owner can migrate
assert!(admin_registry.owner_address == sender, E_NOT_OWNER_ADMIN);
// Version control: Prevent duplicate migrations
assert!(admin_registry.version == 1, E_ALREADY_MIGRATED);
// Apply upgrades to affected objects
admin_registry.version = 2;
admin_registry.max_admins = MAX_ADMINS_V2; // 2 → 4 admins
// Update other shared objects to maintain version consistency
treasury.version = 2;
}
This migration specifically:
Validates authorization - only the original owner can perform migrations
Checks version state - prevents accidental double-migrations
Updates business logic - increases the admin limit from 2 to 4
Maintains consistency - updates all shared objects to the same version
The Upgrade Process in Practice
Let's walk through the complete upgrade workflow:
Step 1: Deploy Version 1
sui client publish --gas-budget 100000000
This creates your initial contract with:
AdminRegistry (version 1, max_admins = 2)
Treasury (version 1)
UpgradeCap (automatically created by Sui)
Step 2: Test Version 1 Functionality
# Add first admin (should work)
sui client call --package <V1_PACKAGE_ID> --module token --function called_by_two_entity \
--args <ADMIN_REGISTRY_ID> <ADMIN_ADDRESS_1> --gas-budget 10000000
# Add second admin (should work)
sui client call --package <V1_PACKAGE_ID> --module token --function called_by_two_entity \
--args <ADMIN_REGISTRY_ID> <ADMIN_ADDRESS_2> --gas-budget 10000000
# Try third admin (should fail - exceeds limit)
sui client call --package <V1_PACKAGE_ID> --module token --function called_by_two_entity \
--args <ADMIN_REGISTRY_ID> <ADMIN_ADDRESS_3> --gas-budget 10000000
Step 3: Prepare Version 2
Update your contract code:
const CURRENT_VERSION: u64 = 2; // Increment version
// Keep all other code the same for this upgrade
Step 4: Execute the Upgrade
sui client upgrade --upgrade-capability <UPGRADE_CAP_ID> --gas-budget 100000000
This command:
Validates the new code is compatible with existing objects
Publishes the new package version
Updates the UpgradeCap to track the new version
Returns a new package ID
Step 5: Run Migration
sui client call --package <V2_PACKAGE_ID> --module token --function migrate \
--args <ADMIN_REGISTRY_ID> <TREASURY_ID> --gas-budget 10000000
Step 6: Verify Upgrade Success
# This should now work (admin #3)
sui client call --package <V2_PACKAGE_ID> --module token --function called_by_two_entity \
--args <ADMIN_REGISTRY_ID> <ADMIN_ADDRESS_3> --gas-budget 10000000
# And admin #4
sui client call --package <V2_PACKAGE_ID> --module token --function called_by_two_entity \
--args <ADMIN_REGISTRY_ID> <ADMIN_ADDRESS_4> --gas-budget 10000000
# Try admin #5 (should fail - new limit is 4)
sui client call --package <V2_PACKAGE_ID> --module token --function called_by_two_entity \
--args <ADMIN_REGISTRY_ID> <ADMIN_ADDRESS_5> --gas-budget 10000000
Understanding Package Coexistence
One of the most interesting aspects of Sui upgrades is that both old and new package versions continue to exist and can interact with the same objects:
# Both of these work immediately after upgrade (before migration)
sui client call --package <V1_PACKAGE_ID> --module token --function called_by_two_entity
sui client call --package <V2_PACKAGE_ID> --module token --function called_by_two_entity
However, after migration, version checks in your functions may cause V1 calls to fail:
// This check will cause V1 calls to fail after migration
assert!(admin_registry.version == 2, E_WRONG_VERSION);
Conclusion
Sui's upgrade system provides a powerful foundation for evolving smart contracts while maintaining user experience and data integrity. The key to successful upgrades lies in:
Planning ahead with version tracking from day one
Understanding the distinction between owned and shared objects
Implementing robust migration functions that handle state transitions safely
Testing thoroughly before production deployment
Following security best practices throughout the upgrade lifecycle
The example we've built demonstrates these principles in action, showing how a simple admin limit change requires careful consideration of state management, access control, and version compatibility.
