Shared Objects and Ownership
SUI's object model is fundamentally different from other blockchains. Instead of a single global state tree, SUI tracks individual objects with distinct ownership models. Understanding these models is critical for designing efficient, correct smart contracts.
The Three Ownership Models
1. Owned Objects
An owned object belongs to a single address. Only the owner can use it in transactions. Because ownership is exclusive, transactions on owned objects can be processed in parallel without consensus -- this is what makes SUI fast.
fun init(ctx: &mut TxContext) {
let admin_cap = AdminCap { id: object::new(ctx) };
transfer::transfer(admin_cap, ctx.sender());
// ^^^^^^^^^^^^
// AdminCap is now owned by the deployer
}transfer::transfer sends an object to a specific address. After this call, only that address can include AdminCap in a transaction.
When to use owned objects:
- Capability tokens (
AdminCap,OwnerCap) - Personal items, NFTs, coins
- Anything that should have a single controller
2. Shared Objects
A shared object has no single owner. Any transaction can read or modify it. This requires consensus (ordered execution), which is slower but necessary for objects that multiple users must interact with.
fun init(ctx: &mut TxContext) {
// ...
let config = ExtensionConfig { id: object::new(ctx) };
transfer::share_object(config);
// ^^^^^^^^^^^^
// ExtensionConfig is now accessible to everyone
}transfer::share_object makes an object shared. This is a one-way operation -- once shared, an object can never be "unshared" or transferred to an owner.
When to use shared objects:
- Configuration that multiple users read (
ExtensionConfig) - Registries, pools, markets
- Any object that must be accessed by multiple parties in the same transaction
3. Immutable Objects
An immutable object can be read by anyone but never modified. Immutable objects, like owned objects, do not require consensus.
transfer::freeze_object(some_object);- Published package metadata
- Constants or lookup tables that never change
- Finalized records
Shared vs. Owned: The Design Tradeoff
The tribe storage extension demonstrates the tradeoff clearly:
``
ExtensionConfig (shared) AdminCap (owned)
| |
Readable by all players Only the admin can use
Writable with AdminCap Required for config changes
Consensus required No consensus needed alone
`
ExtensionConfig must be shared because every tribe member calls share() and withdraw(), both of which read the config to verify tribe membership. If it were owned, only one address could interact with it.
AdminCap should be owned because only the admin needs it. Making it shared would be a security risk -- anyone could reference it in a transaction.
Object References in Functions
SUI Move functions receive objects as references with different permissions:
move// Immutable reference -- can read, cannot modify
public fun has_rule<K: copy + drop + store>(
config: &ExtensionConfig, // <-- & means read-only
key: K,
): bool {
df::exists_(&config.id, key)
}
// Mutable reference -- can read and modify
public fun set_rule<K: copy + drop + store, V: store + drop>(
config: &mut ExtensionConfig, // <-- &mut means read-write
_: &AdminCap,
key: K,
value: V,
) {
// can modify config here
}
For shared objects, SUI distinguishes between read-only and read-write access at the transaction level:
- Read-only (
&ExtensionConfig): Multiple transactions can read the same shared object concurrently.
Read-write ( &mut ExtensionConfig): The transaction gets exclusive write access, requiring consensus ordering.
This means
withdraw() (which only reads config) and set_tribe_config() (which writes config) have different performance characteristics, even though they reference the same object.
Dynamic Fields: Flexible Storage on Objects
SUI objects have a fixed set of fields defined at compile time. But what if you need to add arbitrary data at runtime? That is where dynamic fields come in.
Dynamic fields are key-value pairs attached to any object's
UID. The tribe storage extension uses them to store configuration rules:
moveuse sui::dynamic_field as df;
// Add a dynamic field
public fun add_rule<K: copy + drop + store, V: store>(
config: &mut ExtensionConfig,
_: &AdminCap,
key: K,
value: V,
) {
df::add(&mut config.id, key, value);
}
// Check if a dynamic field exists
public fun has_rule<K: copy + drop + store>(
config: &ExtensionConfig,
key: K,
): bool {
df::exists_(&config.id, key)
}
// Read a dynamic field
public fun borrow_rule<K: copy + drop + store, V: store>(
config: &ExtensionConfig,
key: K,
): &V {
df::borrow(&config.id, key)
}
// Remove a dynamic field
public fun remove_rule<K: copy + drop + store, V: store>(
config: &mut ExtensionConfig,
_: &AdminCap,
key: K,
): V {
df::remove(&mut config.id, key)
}
Dynamic Field Keys
The key type must have
copy + drop + store. A common pattern is to use an empty struct as a typed key:
movepublic struct TribeConfigKey has copy, drop, store {}
public struct TribeConfig has drop, store {
tribe: u32,
}
Using a dedicated key struct gives you type safety --
TribeConfigKey can only be used to look up TribeConfig values. You cannot accidentally use the wrong key type.
Insert-or-Update Pattern
The
set_rule function demonstrates how to upsert a dynamic field:
movepublic fun set_rule<K: copy + drop + store, V: store + drop>(
config: &mut ExtensionConfig,
_: &AdminCap,
key: K,
value: V,
) {
if (df::exists_(&config.id, copy key)) {
let _old: V = df::remove(&mut config.id, copy key);
};
df::add(&mut config.id, key, value);
}
Note the
copy key -- since key has the copy ability, we can duplicate it to use in both the existence check and the add operation. The old value is removed and dropped (it has drop ability) before the new value is added.
Dynamic Fields vs. Regular Fields
Feature Regular Fields Dynamic Fields
Defined at Compile time Runtime
Type-checked Statically Via key type
Enumerable Always Only via off-chain indexing
Gas cost Included in object Separate storage objects
Dynamic fields are ideal for extensible configuration. The
ExtensionConfig object has no idea what rules will be attached to it. Different extensions can store different rule types using different key types.
Object Wrapping and Nesting
Objects with
store can be nested inside other objects. This is called wrapping:
movepublic struct Wrapper has key {
id: UID,
inner: AdminCap, // AdminCap has store, so it can be wrapped
}
A wrapped object is no longer directly accessible -- it can only be accessed through its parent. This is useful for escrow patterns, time-locks, and conditional access.
Key Takeaways
- SUI has three ownership models: owned (single address, fast), shared (anyone, requires consensus), and immutable (frozen, read-only).
transfer::transfer creates owned objects; transfer::share_object creates shared objects; transfer::freeze_object creates immutable objects.
Sharing is a one-way operation -- once shared, always shared.
Use & for read-only access and &mut for read-write access to objects in function parameters.
Dynamic fields attach arbitrary key-value data to objects at runtime, using sui::dynamic_field`.
Sign in to track your progress.