Sui has multiple ownership models: address-owned, shared, immutable, and object-owned (wrapped/child). Incorrect transitions between these models or confusion about which model applies can break invariants and security assumptions.
Risk Level
High — Can lead to complete access control bypass.
Shared → Address-owned — Not possible after sharing
Immutable → Mutable — Not possible after freezing
Object-owned access — Parent owner doesn’t automatically control child
Wrapped objects — UID changes behavior
Vulnerable Example
modulevulnerable::ownership{usesui::object::{Self,UID};usesui::tx_context::TxContext;usesui::transfer;publicstructVaulthaskey,store{id: UID,balance: u64,owner: address,}publicstructVaultControllerhaskey{id: UID,}/// VULNERABLE: Attempts to "unshare" an object
publicentryfunmake_private(vault: Vault,// Taking by value from shared object
new_owner: address,){// This doesn't work as expected!
// Once shared, always shared
// This just moves the shared object, it's still shared
transfer::transfer(vault,new_owner);}/// VULNERABLE: Assumes object ownership = child access
publicentryfunaccess_child_vault(controller: &VaultController,// Can't actually access object-owned objects this way
// The vault would need to be passed separately
){// This function signature is fundamentally broken
// Object ownership doesn't give direct access to children
}/// VULNERABLE: Wrong ownership transition
publicentryfunsetup_vault(ctx: &mutTxContext){letvault=Vault{id: object::new(ctx),balance: 1000,owner: tx_context::sender(ctx),};// Bug: sharing when should be transferring to owner
// Now anyone can access the vault!
transfer::share_object(vault);}/// VULNERABLE: Freezing breaks protocol
publicentryfunpublish_vault(vault: Vault,){// Freezing makes it immutable forever
// Can never update balance again!
transfer::freeze_object(vault);}}
Secure Example
modulesecure::ownership{usesui::object::{Self,UID,ID};usesui::tx_context::{Self,TxContext};usesui::transfer;usesui::dynamic_object_fieldasdof;/// Private vault — only owner can access
publicstructPrivateVaulthaskey{id: UID,balance: u64,}/// Shared pool — anyone can interact (with proper checks)
publicstructSharedPoolhaskey{id: UID,balance: u64,admin_cap_id: ID,}/// Admin capability — controls the shared pool
publicstructPoolAdminCaphaskey{id: UID,pool_id: ID,}/// Published config — immutable by design
publicstructPublishedConfighaskey{id: UID,version: u64,// Only immutable data here
fee_bps: u64,name: vector<u8>,}/// SECURE: Clear ownership from the start
publicentryfuncreate_private_vault(initial_balance: u64,ctx: &mutTxContext){letvault=PrivateVault{id: object::new(ctx),balance: initial_balance,};// Private — only creator can access
transfer::transfer(vault,tx_context::sender(ctx));}/// SECURE: Shared with proper access control
publicentryfuncreate_shared_pool(ctx: &mutTxContext){letpool=SharedPool{id: object::new(ctx),balance: 0,admin_cap_id: object::id_from_address(@0x0),// Placeholder
};letpool_id=object::id(&pool);letcap=PoolAdminCap{id: object::new(ctx),pool_id,};pool.admin_cap_id=object::id(&cap);// Pool is shared, but admin actions require cap
transfer::share_object(pool);transfer::transfer(cap,tx_context::sender(ctx));}/// SECURE: Admin action requires capability
publicentryfunadmin_withdraw(cap: &PoolAdminCap,pool: &mutSharedPool,amount: u64,ctx: &mutTxContext){// Verify cap is for this pool
assert!(cap.pool_id==object::id(pool),E_WRONG_POOL);pool.balance=pool.balance-amount;// ... transfer
}/// SECURE: Published config is intentionally immutable
publicentryfunpublish_config(version: u64,fee_bps: u64,name: vector<u8>,ctx: &mutTxContext){letconfig=PublishedConfig{id: object::new(ctx),version,fee_bps,name,};// Intentionally immutable — this is the design
transfer::freeze_object(config);}/// SECURE: Use dynamic fields for parent-child relationships
publicstructParenthaskey{id: UID,}publicstructChildhaskey,store{id: UID,value: u64,}publicentryfunadd_child_to_parent(parent: &mutParent,child: Child,){dof::add(&mutparent.id,b"child",child);}publicfunaccess_child(parent: &Parent): &Child{dof::borrow(&parent.id,b"child")}publicfunaccess_child_mut(parent: &mutParent): &mutChild{dof::borrow_mut(&mutparent.id,b"child")}}
// Global registries
publicstructRegistryhaskey{}// Liquidity pools
publicstructPoolhaskey{}// Order books
publicstructOrderBookhaskey{}transfer::share_object(obj);
When to Use Immutable
// Published configurations
publicstructConfighaskey{}// Static data
publicstructMetadatahaskey{}// Verified credentials
publicstructCredentialhaskey{}transfer::freeze_object(obj);
When to Use Object-Owned/Dynamic Fields
// Parent-child relationships
publicstructParenthaskey{id: UID,// Children via dynamic fields
}// Encapsulated components
dof::add(&mutparent.id,key,child);
Recommended Mitigations
1. Document Ownership Intent
/// This object is SHARED because:
/// - Multiple users need to interact
/// - Admin actions protected by AdminCap
/// NEVER attempt to unshare
publicstructSharedProtocolhaskey{}
2. Use Type System to Enforce Ownership
/// No `store` = cannot be wrapped or transferred publicly
publicstructMustBeOwnedhaskey{id: UID,}/// Has `store` = can be wrapped in other objects
publicstructCanBeWrappedhaskey,store{id: UID,}
3. Validate Ownership Before Operations
publicentryfunowner_only_action(obj: &mutMyObject,ctx: &TxContext){// For address-owned: ownership enforced by Sui
// For shared: explicit check required
assert!(tx_context::sender(ctx)==obj.owner,E_NOT_OWNER);}
4. Create Clear Ownership Transitions
/// Explicit transition from private to shared
publicentryfunmake_shared(obj: PrivateObject,ctx: &TxContext){assert!(tx_context::sender(ctx)==obj.owner,E_NOT_OWNER);// Convert to shared form
letshared=SharedObject{id: obj.id,data: obj.data,original_owner: obj.owner,};transfer::share_object(shared);}
Testing Checklist
Verify ownership model matches intended access pattern
Test that shared objects cannot be “unshared”
Confirm immutable objects are truly immutable
Test parent-child access patterns with dynamic fields
Verify ownership transitions are intentional and documented