suimoveobject-modelsmart-contractsblockchain-security

Sui Move Object Model: Understanding Ownership and Capabilities

A comprehensive guide to the Sui Move object model, exploring ownership types, capabilities, and best practices for building secure blockchain applications.

Kairo TeamJanuary 27, 202521 min read

Sui Move Object Model: Understanding Ownership and Capabilities

The Sui Move object model represents one of the most significant innovations in blockchain architecture since Ethereum introduced smart contracts. If you're coming from Ethereum, Solana, or other account-based blockchains, Sui's object-centric approach will fundamentally change how you think about on-chain data and asset management.

In this comprehensive guide, we'll explore every aspect of the Sui Move object model—from basic concepts to advanced patterns—with practical code examples you can use in your own projects. Whether you're building DeFi protocols, NFT marketplaces, or security applications like Kairo Guard, understanding objects is essential to building secure and efficient Sui applications.

Why Sui's Object Model is Revolutionary

Traditional blockchains treat state as a global, account-based ledger. Ethereum, for example, stores all data in accounts—either externally owned accounts (EOAs) controlled by private keys or contract accounts with associated code and storage. This model, while proven, creates several challenges:

  • Global state contention: Every transaction potentially touches global state, limiting parallelization
  • Complex access control: Ownership and permissions must be implemented manually in contract logic
  • Reentrancy risks: The shared-state model enables the infamous reentrancy attacks
  • Gas inefficiencies: Reading and writing to large contract storage mappings is expensive

The Sui Move object model takes a radically different approach. Instead of accounts holding balances and data, everything is an object. Your SUI tokens? Objects. Your NFTs? Objects. Your DeFi positions? Objects. Even capability tokens that grant permissions? Objects.

This object-centric design enables:

  1. Parallel transaction execution: Objects with different owners can be modified simultaneously
  2. Native ownership semantics: The blockchain itself enforces who can access what
  3. Elimination of reentrancy: Object ownership rules prevent the classic attack vectors
  4. Predictable gas costs: Object size directly correlates with storage costs

Objects vs Accounts: The Paradigm Shift from Ethereum

Let's make the paradigm shift concrete with a comparison. Consider a simple token balance:

Ethereum Approach (Account Model)

// Ethereum: Balances stored in a contract's mapping
contract Token {
    mapping(address => uint256) public balances;
    
    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

In Ethereum, your "balance" is just a number in a mapping inside a contract. You don't own the tokens—you own an entry in a ledger that the contract manages.

Sui Approach (Object Model)

module example::token {
    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::tx_context::TxContext;

    /// A token object that you actually own
    public struct Token has key, store {
        id: UID,
        value: u64,
    }

    /// Create a new token - the recipient literally receives the object
    public fun mint(value: u64, recipient: address, ctx: &mut TxContext) {
        let token = Token {
            id: object::new(ctx),
            value,
        };
        transfer::public_transfer(token, recipient);
    }

    /// Transfer is just moving the object to a new owner
    public fun transfer_token(token: Token, recipient: address) {
        transfer::public_transfer(token, recipient);
    }
}

In Sui, when you receive tokens, you receive actual objects in your address. The blockchain's runtime—not contract code—enforces that only you can access your objects. This is a fundamental shift from "the contract says you have X" to "you possess X."

Object Types in Sui: A Complete Taxonomy

The Sui Move object model defines four distinct object types, each with different ownership semantics and use cases. Understanding when to use each type is crucial for secure application design.

1. Owned Objects (Single Owner)

Owned objects are the most common type. They have exactly one owner (an address or another object), and only the owner can use them in transactions.

module example::owned {
    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::tx_context::{Self, TxContext};

    /// An owned object - only the owner can access it
    public struct Wallet has key, store {
        id: UID,
        balance: u64,
        name: vector<u8>,
    }

    /// Create a wallet owned by the transaction sender
    public fun create_wallet(name: vector<u8>, ctx: &mut TxContext) {
        let wallet = Wallet {
            id: object::new(ctx),
            balance: 0,
            name,
        };
        // Transfer to sender - they become the sole owner
        transfer::transfer(wallet, tx_context::sender(ctx));
    }

    /// Only the owner can call this (enforced by passing `wallet` by value/ref)
    public fun deposit(wallet: &mut Wallet, amount: u64) {
        wallet.balance = wallet.balance + amount;
    }

    /// Withdraw requires ownership - the object must be in your possession
    public fun withdraw(wallet: &mut Wallet, amount: u64): u64 {
        assert!(wallet.balance >= amount, 0);
        wallet.balance = wallet.balance - amount;
        amount
    }
}

Key characteristics of owned objects:

  • Transactions using owned objects can execute in parallel (no global locks)
  • The owner can transfer ownership to another address
  • Only the current owner can include the object in a transaction
  • Perfect for: user wallets, NFTs, personal credentials, capability tokens

2. Shared Objects (Mutable by Anyone)

Shared objects can be accessed by anyone. They're essential for building protocols where multiple users need to interact with the same state—like AMM pools, orderbooks, or global registries.

module example::shared {
    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::tx_context::TxContext;

    /// A shared liquidity pool - anyone can interact with it
    public struct Pool has key {
        id: UID,
        reserve_a: u64,
        reserve_b: u64,
        total_shares: u64,
    }

    /// Create a shared pool - note: share_object, not transfer
    public fun create_pool(ctx: &mut TxContext) {
        let pool = Pool {
            id: object::new(ctx),
            reserve_a: 0,
            reserve_b: 0,
            total_shares: 0,
        };
        // share_object makes this accessible to everyone
        transfer::share_object(pool);
    }

    /// Anyone can add liquidity
    public fun add_liquidity(
        pool: &mut Pool, 
        amount_a: u64, 
        amount_b: u64
    ): u64 {
        pool.reserve_a = pool.reserve_a + amount_a;
        pool.reserve_b = pool.reserve_b + amount_b;
        
        // Calculate and return shares (simplified)
        let shares = amount_a + amount_b; // Real formula would be more complex
        pool.total_shares = pool.total_shares + shares;
        shares
    }

    /// Anyone can swap tokens
    public fun swap_a_for_b(pool: &mut Pool, amount_in: u64): u64 {
        // Constant product formula (simplified)
        let amount_out = (amount_in * pool.reserve_b) / (pool.reserve_a + amount_in);
        pool.reserve_a = pool.reserve_a + amount_in;
        pool.reserve_b = pool.reserve_b - amount_out;
        amount_out
    }
}

Key characteristics of shared objects:

  • Transactions using the same shared object must be sequenced (ordered by consensus)
  • Cannot be transferred or converted back to owned
  • Anyone can pass them as arguments to functions expecting &mut or & references
  • Perfect for: DEX pools, governance contracts, global registries, auction houses

3. Immutable Objects

Once an object is made immutable, it can never be modified or deleted. Anyone can read it, making immutable objects perfect for on-chain constants, published metadata, or verified data.

module example::immutable {
    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::tx_context::TxContext;

    /// Configuration that should never change
    public struct ProtocolConfig has key {
        id: UID,
        version: u64,
        max_supply: u64,
        fee_basis_points: u64,
        admin_address: address,
    }

    /// Create and immediately freeze the config
    public fun publish_config(
        version: u64,
        max_supply: u64,
        fee_basis_points: u64,
        admin_address: address,
        ctx: &mut TxContext
    ) {
        let config = ProtocolConfig {
            id: object::new(ctx),
            version,
            max_supply,
            fee_basis_points,
            admin_address,
        };
        // freeze_object makes this permanently immutable
        transfer::freeze_object(config);
    }

    /// Anyone can read the config (pass by immutable reference)
    public fun get_max_supply(config: &ProtocolConfig): u64 {
        config.max_supply
    }

    public fun get_fee(config: &ProtocolConfig): u64 {
        config.fee_basis_points
    }
}

Key characteristics of immutable objects:

  • Can be read by anyone (passed as &T)
  • Cannot be modified, transferred, or deleted—ever
  • Transactions reading immutable objects can execute in parallel
  • Perfect for: protocol parameters, metadata, verified proofs, published content

4. Wrapped Objects (Object Inside Object)

Wrapped objects are owned by another object rather than an address. This pattern enables complex ownership hierarchies and is crucial for building composable protocols.

module example::wrapped {
    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::tx_context::{Self, TxContext};

    /// An inner object that will be wrapped
    public struct InnerAsset has key, store {
        id: UID,
        value: u64,
    }

    /// A container that wraps the inner asset
    public struct Vault has key {
        id: UID,
        asset: InnerAsset,  // Wrapped - owned by the Vault, not an address
        unlock_time: u64,
    }

    /// Wrap an asset in a time-locked vault
    public fun lock_asset(
        asset: InnerAsset, 
        unlock_time: u64, 
        ctx: &mut TxContext
    ) {
        let vault = Vault {
            id: object::new(ctx),
            asset,  // The asset is now wrapped inside the vault
            unlock_time,
        };
        transfer::transfer(vault, tx_context::sender(ctx));
    }

    /// Unwrap and retrieve the asset (only after unlock time)
    public fun unlock_asset(vault: Vault, current_time: u64): InnerAsset {
        let Vault { id, asset, unlock_time } = vault;
        assert!(current_time >= unlock_time, 0); // Must wait until unlock
        object::delete(id);
        asset  // Return the unwrapped asset
    }
}

Key characteristics of wrapped objects:

  • The wrapped object's ID still exists but isn't directly accessible
  • Only the parent object's owner can access the wrapped object
  • Useful for temporary custody, escrow, and composable protocols
  • Perfect for: escrow services, time locks, bundled assets, nested ownership

Ownership and Transfers: Moving Objects Between Addresses

Understanding transfer semantics is essential for the Sui Move object model. Let's explore the transfer module's key functions:

module example::transfers {
    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::tx_context::{Self, TxContext};

    public struct Asset has key, store {
        id: UID,
        data: vector<u8>,
    }

    public struct RestrictedAsset has key {
        id: UID,
        data: vector<u8>,
    }

    // ============ Transfer Functions ============

    /// transfer::transfer - Module-controlled transfer (has `key` only)
    /// Only the defining module can transfer RestrictedAsset
    public fun transfer_restricted(asset: RestrictedAsset, recipient: address) {
        transfer::transfer(asset, recipient);
    }

    /// transfer::public_transfer - Anyone can transfer (has `key` + `store`)
    /// The `store` ability enables permissionless transfers
    public fun transfer_freely(asset: Asset, recipient: address) {
        transfer::public_transfer(asset, recipient);
    }

    // ============ Sharing Functions ============

    /// transfer::share_object - Make owned object shared (irreversible)
    public fun make_shared(asset: Asset) {
        transfer::public_share_object(asset);
    }

    // ============ Freezing Functions ============

    /// transfer::freeze_object - Make object immutable (irreversible)
    public fun make_immutable(asset: Asset) {
        transfer::public_freeze_object(asset);
    }

    // ============ Receiving Transfers ============
    
    /// Objects can be sent to other objects, not just addresses
    public struct Mailbox has key {
        id: UID,
    }

    /// Send an asset to a Mailbox object
    public fun send_to_mailbox(asset: Asset, mailbox_id: address) {
        transfer::public_transfer(asset, mailbox_id);
    }
}

Transfer Rules Summary

| Function | Requirements | Use Case | |----------|--------------|----------| | transfer::transfer | key ability | Module-controlled transfers | | transfer::public_transfer | key + store abilities | Permissionless transfers | | transfer::share_object | key ability | Make object shared (module only) | | transfer::public_share_object | key + store abilities | Make object shared (anyone) | | transfer::freeze_object | key ability | Make immutable (module only) | | transfer::public_freeze_object | key + store abilities | Make immutable (anyone) |

Abilities in Move: copy, drop, store, key

Move's ability system is the foundation of the Sui Move object model's safety guarantees. Every struct in Move must explicitly declare what operations are permitted on its instances.

The Four Abilities Explained

module example::abilities {
    use sui::object::UID;

    // ============ copy ============
    // Values can be duplicated
    public struct Copyable has copy, drop {
        value: u64,
    }

    public fun demonstrate_copy() {
        let x = Copyable { value: 100 };
        let y = x;      // This is a COPY, not a move
        let z = x;      // x is still valid, can copy again
        // x, y, z all exist independently
    }

    // ============ drop ============
    // Values can be discarded/ignored
    public struct Droppable has drop {
        value: u64,
    }

    public fun demonstrate_drop() {
        let x = Droppable { value: 100 };
        // Function ends without using x - that's OK because of `drop`
        // Without `drop`, this would be a compile error
    }

    // ============ store ============
    // Values can be stored inside other structs and transferred freely
    public struct Storable has store {
        value: u64,
    }

    public struct Container has key, store {
        id: UID,
        inner: Storable,  // Storable can be a field because it has `store`
    }

    // ============ key ============
    // Struct is an object with a globally unique ID
    // MUST have `id: UID` as first field
    public struct MyObject has key {
        id: UID,
        data: u64,
    }

    // ============ Common Combinations ============

    /// Standard transferable object (most NFTs, tokens)
    public struct StandardAsset has key, store {
        id: UID,
        value: u64,
    }

    /// Non-transferable object (soulbound, credentials)
    public struct SoulboundAsset has key {
        id: UID,
        owner_name: vector<u8>,
        // No `store` = only the module can transfer this
    }

    /// Pure data struct (not an object, can be embedded)
    public struct Metadata has store, copy, drop {
        name: vector<u8>,
        description: vector<u8>,
    }

    /// Linear resource (must be explicitly consumed)
    public struct Receipt has store {
        amount_paid: u64,
        // No `copy` = can't duplicate
        // No `drop` = must be explicitly handled
        // Common for receipts, tickets, hot potatoes
    }
}

Ability Requirements for Objects

| Pattern | Abilities | Behavior | |---------|-----------|----------| | Standard Object | key, store | Freely transferable by anyone | | Soulbound/Restricted | key only | Only defining module can transfer | | Embeddable Data | store | Can be stored in objects, not an object itself | | Hot Potato | store (no drop) | Must be consumed, can't be ignored | | Pure Value | copy, drop | Can be freely copied and discarded |

Object IDs and Versioning

Every object in Sui has a unique identifier and version number. Understanding this system is crucial for building robust applications.

module example::versioning {
    use sui::object::{Self, UID, ID};
    use sui::transfer;
    use sui::tx_context::{Self, TxContext};
    use sui::event;

    public struct VersionedData has key, store {
        id: UID,
        data: vector<u8>,
        // Note: version is tracked automatically by Sui runtime
    }

    /// Event to log object creation with its ID
    public struct ObjectCreated has copy, drop {
        object_id: ID,
        creator: address,
    }

    public fun create_and_log(data: vector<u8>, ctx: &mut TxContext): ID {
        let versioned = VersionedData {
            id: object::new(ctx),
            data,
        };
        
        // Get the ID before transferring
        let id = object::id(&versioned);
        
        // Emit event with the object ID
        event::emit(ObjectCreated {
            object_id: id,
            creator: tx_context::sender(ctx),
        });

        transfer::public_transfer(versioned, tx_context::sender(ctx));
        id
    }

    /// Get the unique ID of an object
    public fun get_id(obj: &VersionedData): ID {
        object::id(obj)
    }

    /// Get the raw bytes of an object's ID
    public fun get_id_bytes(obj: &VersionedData): vector<u8> {
        object::id_bytes(obj)
    }

    /// Get the address representation of an ID (for sending objects to objects)
    public fun get_id_address(obj: &VersionedData): address {
        object::id_address(obj)
    }

    /// Convert an ID to address (objects can receive transfers too)
    public fun id_to_address(id: ID): address {
        object::id_to_address(&id)
    }
}

How Versioning Works

  1. Creation: Object starts at version 1
  2. Mutation: Each transaction that modifies the object increments its version
  3. Transaction Input: When using an object, you specify (object_id, version)
  4. Optimistic Execution: If the version doesn't match, the transaction fails

This versioning system enables Sui's parallel execution model—transactions touching different objects (or the same object at compatible versions) can execute simultaneously.

Code Examples: Real-World Patterns

Let's look at comprehensive examples that combine everything we've learned about the Sui Move object model.

Example 1: Creating Objects with Capabilities

module example::cap_pattern {
    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::tx_context::{Self, TxContext};

    /// Admin capability - grants administrative powers
    public struct AdminCap has key, store {
        id: UID,
    }

    /// The main protocol object
    public struct Protocol has key {
        id: UID,
        paused: bool,
        fee_rate: u64,
    }

    /// One-time witness for initialization
    public struct PROTOCOL has drop {}

    /// Initialize the protocol - called once on module publish
    fun init(witness: PROTOCOL, ctx: &mut TxContext) {
        // Create admin capability for the deployer
        let admin_cap = AdminCap {
            id: object::new(ctx),
        };
        transfer::transfer(admin_cap, tx_context::sender(ctx));

        // Create and share the protocol
        let protocol = Protocol {
            id: object::new(ctx),
            paused: false,
            fee_rate: 100, // 1%
        };
        transfer::share_object(protocol);
    }

    /// Only admin can pause (must possess AdminCap)
    public fun pause(
        _admin: &AdminCap,  // Proves ownership of admin capability
        protocol: &mut Protocol
    ) {
        protocol.paused = true;
    }

    /// Only admin can update fee
    public fun set_fee_rate(
        _admin: &AdminCap,
        protocol: &mut Protocol,
        new_rate: u64
    ) {
        assert!(new_rate <= 1000, 0); // Max 10%
        protocol.fee_rate = new_rate;
    }

    /// Transfer admin rights to a new address
    public fun transfer_admin(admin: AdminCap, new_admin: address) {
        transfer::public_transfer(admin, new_admin);
    }
}

Example 2: Shared Object Patterns

module example::orderbook {
    use sui::object::{Self, UID, ID};
    use sui::transfer;
    use sui::tx_context::{Self, TxContext};
    use sui::table::{Self, Table};

    /// A shared orderbook anyone can interact with
    public struct Orderbook has key {
        id: UID,
        bids: Table<u64, Order>,  // price -> order
        asks: Table<u64, Order>,
        next_order_id: u64,
    }

    public struct Order has store {
        id: u64,
        owner: address,
        amount: u64,
        price: u64,
    }

    /// Receipt proving an order was placed
    public struct OrderReceipt has key, store {
        id: UID,
        orderbook_id: ID,
        order_id: u64,
        is_bid: bool,
    }

    /// Create a shared orderbook
    public fun create_orderbook(ctx: &mut TxContext) {
        let orderbook = Orderbook {
            id: object::new(ctx),
            bids: table::new(ctx),
            asks: table::new(ctx),
            next_order_id: 0,
        };
        transfer::share_object(orderbook);
    }

    /// Place a bid (anyone can call this with the shared orderbook)
    public fun place_bid(
        orderbook: &mut Orderbook,
        amount: u64,
        price: u64,
        ctx: &mut TxContext
    ): OrderReceipt {
        let order_id = orderbook.next_order_id;
        orderbook.next_order_id = order_id + 1;

        let order = Order {
            id: order_id,
            owner: tx_context::sender(ctx),
            amount,
            price,
        };

        table::add(&mut orderbook.bids, price, order);

        // Return receipt to the order placer
        OrderReceipt {
            id: object::new(ctx),
            orderbook_id: object::id(orderbook),
            order_id,
            is_bid: true,
        }
    }

    /// Cancel order (need the receipt as proof)
    public fun cancel_order(
        orderbook: &mut Orderbook,
        receipt: OrderReceipt,
        ctx: &TxContext
    ) {
        let OrderReceipt { id, orderbook_id: _, order_id: _, is_bid } = receipt;
        object::delete(id);
        
        // Would remove from bids or asks based on is_bid
        // Simplified for example
    }
}

How Kairo Uses Objects for Policy Storage

At Kairo Guard, we leverage the Sui Move object model to create a powerful, flexible security framework. Our policy system demonstrates advanced object patterns in action.

Policy Objects in Kairo

module kairo::policy {
    use sui::object::{Self, UID, ID};
    use sui::transfer;
    use sui::tx_context::{Self, TxContext};
    use sui::vec_set::{Self, VecSet};

    /// A security policy owned by a user
    public struct Policy has key, store {
        id: UID,
        owner: address,
        
        // Spending limits
        daily_limit: u64,
        per_tx_limit: u64,
        spent_today: u64,
        
        // Allowlists
        allowed_contracts: VecSet<address>,
        allowed_recipients: VecSet<address>,
        
        // Security settings
        require_2fa: bool,
        delay_large_transfers: bool,
    }

    /// Guardian capability - can help recover or approve transactions
    public struct GuardianCap has key, store {
        id: UID,
        policy_id: ID,
        guardian: address,
    }

    /// Create a new policy with default secure settings
    public fun create_policy(ctx: &mut TxContext): Policy {
        Policy {
            id: object::new(ctx),
            owner: tx_context::sender(ctx),
            daily_limit: 1000_000_000, // 1000 SUI
            per_tx_limit: 100_000_000,  // 100 SUI
            spent_today: 0,
            allowed_contracts: vec_set::empty(),
            allowed_recipients: vec_set::empty(),
            require_2fa: true,
            delay_large_transfers: true,
        }
    }

    /// Add a guardian who can help with recovery
    public fun add_guardian(
        policy: &Policy,
        guardian_address: address,
        ctx: &mut TxContext
    ): GuardianCap {
        assert!(tx_context::sender(ctx) == policy.owner, 0);
        
        GuardianCap {
            id: object::new(ctx),
            policy_id: object::id(policy),
            guardian: guardian_address,
        }
    }

    /// Check if a transaction is allowed by the policy
    public fun check_transaction(
        policy: &Policy,
        recipient: address,
        amount: u64,
    ): bool {
        // Check spending limits
        if (amount > policy.per_tx_limit) {
            return false
        };
        if (policy.spent_today + amount > policy.daily_limit) {
            return false
        };
        
        // Check allowlists (if not empty, recipient must be allowed)
        if (!vec_set::is_empty(&policy.allowed_recipients)) {
            if (!vec_set::contains(&policy.allowed_recipients, &recipient)) {
                return false
            }
        };
        
        true
    }
}

Why Objects Work for Security

Kairo's object-based approach provides several security advantages:

  1. Ownership Guarantees: Your policy object is yours. No one else can modify it without your authorization—enforced at the protocol level, not just in contract logic.

  2. Capability-Based Access: Guardian capabilities are objects that prove authorization. Losing access? Your guardian's capability object lets them help you recover.

  3. Atomic Updates: Policy changes happen in single transactions. No risk of partial updates or inconsistent state.

  4. Auditability: Every policy has a unique ID and version history on-chain. Changes are transparent and verifiable.

Best Practices for Object Design

Based on our experience building Kairo and working with the Sui Move object model, here are essential best practices:

1. Choose the Right Object Type

// ✅ GOOD: User assets should be owned objects
public struct UserNFT has key, store { id: UID, ... }

// ✅ GOOD: Global state should be shared
public struct GlobalConfig has key { id: UID, ... }

// ✅ GOOD: Constants should be immutable
public struct ProtocolParams has key { id: UID, ... }
// transfer::freeze_object(params);

// ❌ BAD: Don't make user assets shared (anyone could modify)
// ❌ BAD: Don't make frequently-updated state immutable

2. Use Capabilities for Access Control

// ✅ GOOD: Capability pattern
public struct AdminCap has key, store { id: UID }
public fun admin_action(_cap: &AdminCap, ...) { ... }

// ❌ BAD: Address checks in function (can't delegate)
public fun admin_action(ctx: &TxContext) {
    assert!(tx_context::sender(ctx) == @admin, 0);
}

3. Consider Ability Combinations Carefully

// ✅ Standard transferable asset
public struct Token has key, store { id: UID, ... }

// ✅ Soulbound credential (module controls transfers)
public struct Credential has key { id: UID, ... }

// ✅ Hot potato pattern (must be consumed)
public struct FlashLoanReceipt has store { amount: u64 }
// No drop = must call repay()

4. Minimize Shared Object Usage

// ✅ GOOD: Use owned objects when possible for parallelism
// Each user has their own position object
public struct Position has key, store { id: UID, ... }

// ⚠️ CAREFUL: Shared objects create sequencing bottlenecks
// Only share when truly necessary (global pools, orderbooks)
public struct Pool has key { id: UID, ... }

Common Pitfalls and How to Avoid Them

Pitfall 1: Forgetting to Handle Linear Resources

// ❌ BAD: Compile error - Receipt has no `drop`, can't be ignored
public fun borrow(pool: &mut Pool): (Coin<SUI>, Receipt) { ... }

public fun bad_usage(pool: &mut Pool) {
    let (coin, receipt) = borrow(pool);
    // Error: receipt is not used and can't be dropped
}

// ✅ GOOD: Must explicitly handle the receipt
public fun good_usage(pool: &mut Pool) {
    let (coin, receipt) = borrow(pool);
    // ... use the coin ...
    repay(pool, coin, receipt); // Receipt consumed here
}

Pitfall 2: Shared Object Lock Contention

// ❌ BAD: Every user action touches the same shared object
public struct BadDesign has key {
    id: UID,
    all_user_balances: Table<address, u64>,  // Bottleneck!
}

// ✅ GOOD: Each user has their own object
public struct GoodDesign has key, store {
    id: UID,
    balance: u64,
}
// Users operate on their own objects in parallel

Pitfall 3: Incorrect Ability Specifications

// ❌ BAD: Can't store this in other objects
public struct Data has key {
    id: UID,
    value: u64,
}

public struct Container has key {
    id: UID,
    data: Data,  // Error! Data needs `store` ability
}

// ✅ GOOD: Add `store` if the object needs to be wrapped
public struct Data has key, store {
    id: UID,
    value: u64,
}

Pitfall 4: Exposing Mutable References Unsafely

// ❌ BAD: Anyone with a reference can drain the pool
public fun get_pool_mut(pool: &mut Pool): &mut u64 {
    &mut pool.balance
}

// ✅ GOOD: Controlled mutations through specific functions
public fun deposit(pool: &mut Pool, amount: u64) {
    pool.balance = pool.balance + amount;
}

public fun withdraw(pool: &mut Pool, amount: u64, ctx: &TxContext) {
    assert!(is_authorized(pool, ctx), 0);
    pool.balance = pool.balance - amount;
}

Conclusion

The Sui Move object model represents a fundamental rethinking of how blockchain state should work. By making objects first-class citizens with native ownership, Sui eliminates entire categories of vulnerabilities while enabling unprecedented parallelism.

Key takeaways:

  • Objects replace accounts as the fundamental unit of state
  • Four object types (owned, shared, immutable, wrapped) serve different needs
  • Abilities (key, store, copy, drop) define what operations are safe
  • Capability patterns provide flexible, delegatable access control
  • Object IDs and versioning enable parallel execution

At Kairo Guard, we've built our entire security model on these primitives. Your policies are objects you truly own. Guardian capabilities are transferable tokens of trust. Every security check is enforced by the protocol itself, not just by our code.

Understanding the Sui Move object model isn't just academic—it's the key to building secure, efficient, and user-respecting applications on Sui. Whether you're building DeFi protocols, NFT platforms, or security tools, these patterns will serve you well.

Ready to see these concepts in action? Try Kairo Guard and experience object-based security firsthand.


This article is part of Kairo's technical deep-dive series, helping developers build secure applications on Sui. Follow us for more Move programming guides and blockchain security insights.

Ready to secure your crypto?

Kairo Guard brings 2PC-MPC security and policy-gated transactions to your existing wallet. No seed phrases, no single points of failure.

Get Early Access

© 2026 Kairo Guard. All rights reserved.