Cycle

The Scriptorium

Smart Assembly code templates and tools for on-chain development in Eve Frontier.

Move Extension Basics
BeginnerChapter 3 of 420 min read

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.

move
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.

move
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.

move
transfer::freeze_object(some_object);
When to use immutable objects:
  • 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:

move
use 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:

move
public 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:

move
public 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

FeatureRegular FieldsDynamic Fields
Defined atCompile timeRuntime
Type-checkedStaticallyVia key type
EnumerableAlwaysOnly via off-chain indexing
Gas costIncluded in objectSeparate 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:

move
public 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`.
  • Design choice: make objects shared only when multiple users must access them. Keep everything else owned for better parallelism.

Sign in to track your progress.