Upgrade boundary errors occur when package upgrades break compatibility with existing on-chain state, cause ABI mismatches, or violate upgrade policies. Sui Move packages can be upgraded, but upgrades must maintain compatibility with objects created by previous versions. Failing to handle upgrade boundaries correctly can corrupt state, break integrations, or lock funds permanently.
Risk Level
Critical — Can lead to permanent protocol breakage or locked funds.
OWASP / CWE Mapping
OWASP Top 10
MITRE CWE
A04 (Insecure Design) / A06 (Vulnerable and Outdated Components)
CWE-685 (Function Call With Incorrect Number of Arguments), CWE-694 (Use of Multiple Resources with Duplicate Identifier)
// === VERSION 1 (Original Deployment) ===
modulevulnerable::token_v1{usesui::object::{Self,UID};usesui::tx_context::TxContext;publicstructTokenhaskey,store{id: UID,value: u64,owner: address,}publicfunget_value(token: &Token): u64{token.value}publicfuntransfer_value(from: &mutToken,to: &mutToken,amount: u64){from.value=from.value-amount;to.value=to.value+amount;}}// === VERSION 2 (BROKEN UPGRADE) ===
modulevulnerable::token_v2{usesui::object::{Self,UID};usesui::tx_context::TxContext;/// VULNERABLE: Added field breaks existing Token objects!
publicstructTokenhaskey,store{id: UID,value: u64,owner: address,created_at: u64,// NEW FIELD - breaks deserialization!
}/// VULNERABLE: Changed function signature
publicfunget_value(token: &Token,_ctx: &TxContext): u64{// Added parameter breaks all callers
token.value}/// VULNERABLE: Removed function breaks dependent packages
// transfer_value is gone!
/// VULNERABLE: New function with same logic but different name
publicfunsend_value(from: &mutToken,to: &mutToken,amount: u64,fee: u64,// Added fee parameter
){from.value=from.value-amount-fee;to.value=to.value+amount;}}// === ANOTHER BROKEN PATTERN ===
modulevulnerable::registry_v1{publicstructRegistryhaskey{id: UID,entries: vector<Entry>,}publicstructEntryhasstore{name: vector<u8>,value: u64,}}modulevulnerable::registry_v2{publicstructRegistryhaskey{id: UID,/// VULNERABLE: Changed from vector to Table
/// Existing Registry objects can't be read!
entries: Table<vector<u8>,Entry>,}/// VULNERABLE: Entry struct layout changed
publicstructEntryhasstore{value: u64,// Reordered!
name: vector<u8>,active: bool,// Added field
}}
Breaking Scenarios
// Scenario 1: Existing objects become unreadable
publicentryfunuse_old_token(token: &Token){// After v2 upgrade, Token struct has different layout
// Old tokens created in v1 can't be deserialized
// This function will abort!
}// Scenario 2: Dependent packages break
moduleother_package::integration{usevulnerable::token_v1;// Compiled against v1
publicfundo_transfer(from: &mutToken,to: &mutToken){// After upgrade, transfer_value doesn't exist
// This call fails at runtime
token_v1::transfer_value(from,to,100);}}// Scenario 3: Type parameter mismatch
modulevulnerable::pool_v1{publicstructPool<phantomT>haskey{id: UID,balance: Balance<T>,}}modulevulnerable::pool_v2{// Changed phantom to non-phantom - incompatible!
publicstructPool<T: store>haskey{id: UID,balance: Balance<T>,fee_rate: u64,}}
Secure Example
// === VERSION 1 (Original - Upgrade-Safe Design) ===
modulesecure::token_v1{usesui::object::{Self,UID};usesui::tx_context::TxContext;usesui::dynamic_fieldasdf;constVERSION: u64=1;/// Core struct - fields are FINAL after deployment
publicstructTokenhaskey,store{id: UID,value: u64,owner: address,version: u64,// Track version for migrations
}/// Use dynamic fields for extensibility
publicfunset_metadata(token: &mutToken,key: vector<u8>,value: vector<u8>){df::add(&muttoken.id,key,value);}publicfunget_value(token: &Token): u64{token.value}/// Keep original function signature forever
publicfuntransfer_value(from: &mutToken,to: &mutToken,amount: u64){transfer_value_internal(from,to,amount,0);}/// Internal implementation can change
funtransfer_value_internal(from: &mutToken,to: &mutToken,amount: u64,_fee: u64){from.value=from.value-amount;to.value=to.value+amount;}publicfuncreate(value: u64,ctx: &mutTxContext): Token{Token{id: object::new(ctx),value,owner: tx_context::sender(ctx),version: VERSION,}}}// === VERSION 2 (Safe Upgrade) ===
modulesecure::token_v2{usesui::object::{Self,UID};usesui::tx_context::TxContext;usesui::dynamic_fieldasdf;constVERSION: u64=2;constCREATED_AT_KEY: vector<u8>=b"created_at";/// SECURE: Struct layout is IDENTICAL to v1
publicstructTokenhaskey,store{id: UID,value: u64,owner: address,version: u64,}/// SECURE: Original function signature preserved
publicfunget_value(token: &Token): u64{token.value}/// SECURE: Original function still works
publicfuntransfer_value(from: &mutToken,to: &mutToken,amount: u64){// Internally uses new logic with zero fee
transfer_value_with_fee(from,to,amount,0);}/// SECURE: New functionality via new function
publicfuntransfer_value_with_fee(from: &mutToken,to: &mutToken,amount: u64,fee: u64){from.value=from.value-amount-fee;to.value=to.value+amount;}/// SECURE: New data stored in dynamic fields
publicfunset_created_at(token: &mutToken,timestamp: u64){if(df::exists_(&token.id,CREATED_AT_KEY)){*df::borrow_mut(&muttoken.id,CREATED_AT_KEY)=timestamp;}else{df::add(&muttoken.id,CREATED_AT_KEY,timestamp);};}publicfunget_created_at(token: &Token): Option<u64>{if(df::exists_(&token.id,CREATED_AT_KEY)){option::some(*df::borrow(&token.id,CREATED_AT_KEY))}else{option::none()}}/// SECURE: Migration function for version updates
publicfunmigrate_token(token: &mutToken,clock: &Clock){if(token.version<VERSION){// Perform any necessary migration
if(!df::exists_(&token.id,CREATED_AT_KEY)){df::add(&muttoken.id,CREATED_AT_KEY,clock::timestamp_ms(clock));};token.version=VERSION;};}/// New tokens get new features automatically
publicfuncreate(value: u64,clock: &Clock,ctx: &mutTxContext): Token{letmuttoken=Token{id: object::new(ctx),value,owner: tx_context::sender(ctx),version: VERSION,};df::add(&muttoken.id,CREATED_AT_KEY,clock::timestamp_ms(clock));token}}
Upgrade-Safe Patterns
Pattern 1: Version Tracking
constCURRENT_VERSION: u64=3;publicstructProtocolhaskey{id: UID,version: u64,// ... other fields unchanged
}publicfuncheck_version(protocol: &Protocol){assert!(protocol.version<=CURRENT_VERSION,E_FUTURE_VERSION);}publicentryfunmigrate(protocol: &mutProtocol,ctx: &TxContext){// Only admin can migrate
assert!(is_admin(ctx),E_NOT_ADMIN);if(protocol.version==1){// v1 -> v2 migration logic
protocol.version=2;};if(protocol.version==2){// v2 -> v3 migration logic
protocol.version=3;};}
Pattern 2: Dynamic Fields for Extensions
/// Never add fields to this struct after deployment
publicstructCoreDatahaskey{id: UID,essential_field: u64,}/// Extension data lives in dynamic fields
publicfunadd_extension<T: store>(core: &mutCoreData,key: vector<u8>,data: T){df::add(&mutcore.id,key,data);}publicfunget_extension<T: store>(core: &CoreData,key: vector<u8>): &T{df::borrow(&core.id,key)}
Pattern 3: Wrapper Structs for New Versions
// V1 struct - never changes
publicstructDataV1haskey,store{id: UID,value: u64,}// V2 adds features via wrapper
publicstructDataV2Wrapperhaskey{id: UID,inner: DataV1,// Contains V1 data
new_field: u64,// New functionality
}// Migration function
publicfunupgrade_to_v2(data: DataV1,ctx: &mutTxContext): DataV2Wrapper{DataV2Wrapper{id: object::new(ctx),inner: data,new_field: 0,}}
Pattern 4: Function Deprecation (Not Removal)
/// Original function - NEVER remove, keep forever
publicfunold_function(data: &Data): u64{// Delegate to new implementation
new_function(data,default_options())}/// New function with more features
publicfunnew_function(data: &Data,options: Options): u64{// New implementation
}/// Mark as deprecated in documentation, not in code
/// #[deprecated = "Use new_function instead"]
/// But the function still works!
Pattern 5: Upgrade Capability Control
publicstructUpgradeCaphaskey{id: UID,package_id: ID,policy: u8,// 0=compatible, 1=additive, 2=dep_only
}publicfunrestrict_upgrades(cap: &mutUpgradeCap,new_policy: u8){// Can only make more restrictive
assert!(new_policy>=cap.policy,E_CANNOT_RELAX_POLICY);cap.policy=new_policy;}publicfunmake_immutable(cap: UpgradeCap){// Destroy cap - no more upgrades possible
letUpgradeCap{id,package_id: _,policy: _}=cap;object::delete(id);}
Upgrade Checklist
Before Upgrading
1. [ ] All struct layouts are IDENTICAL to previous version
2. [ ] No fields added, removed, or reordered in existing structs
3. [ ] All public function signatures are unchanged
4. [ ] No public functions removed
5. [ ] Type parameters unchanged (phantom status, constraints)
6. [ ] New functionality uses new functions or dynamic fields
7. [ ] Migration path exists for version-specific logic
Compatible Changes (Allowed)
// ✅ Add new public functions
publicfunnew_feature(){}// ✅ Add new structs
publicstructNewDatahaskey{}// ✅ Change function implementations (not signatures)
publicfunexisting_fn(): u64{// Changed implementation is OK
new_calculation()}// ✅ Add private/internal functions
funhelper(){}// ✅ Use dynamic fields for new data
df::add(&mutobj.id,b"new_data",value);
Incompatible Changes (Forbidden)
// ❌ Change struct fields
publicstructData{new_field: u64,// BREAKS existing objects
}// ❌ Change function signature
publicfunfunc(new_param: u64){}// BREAKS callers
// ❌ Remove public function
// (deleted function) // BREAKS dependent packages
// ❌ Change type parameters
publicstructPool<T: store>{}// Was phantom T
// ❌ Change abilities
publicstructDatahaskey{}// Had key, store
Recommended Mitigations
1. Design for Extensibility from Day One
publicstructProtocolhaskey{id: UID,version: u64,// Always include version
// Core fields only - use dynamic fields for rest
}
2. Never Remove Public Functions
// Keep old function, delegate to new
publicfunold_api(): u64{new_api(defaults())}publicfunnew_api(opts: Options): u64{/* new impl */}
3. Use Dynamic Fields for Extensions
// Add new data without changing struct
df::add(&mutobj.id,b"v2_data",new_data);
4. Test Upgrades Thoroughly
#[test]funtest_upgrade_compatibility(){// Create objects with v1
// Upgrade package
// Verify v1 objects still work with v2 code
}