23 min read

A Gentle Introduction to Sui Move, for Solidity developers

For when you want your Solidity devs to cry in a new language

Table of Contents

As a Solidity developer who got into the Sui world, with no previous experience of Rust, the first contact was a bit overwhelming for me. Sui-flavored Move doesn’t follow the object-oriented programming (OOP) paradigm that you can find in the most popular high-level languages used today. Rust concepts are also recycled, such as borrowing or typing, which are not explicitly explained in the official Sui documentation.

This goal aims to help other Solidity and OOP devs in general, to get up to speed with with the main concepts used in Sui-Move programming. Especially if you had to grok everything in a week, like me.

Move Objects

To start, let’s talk about a heavily marketed feature of Sui: objects. This may sound confusing, as objects are a well-defined category in OOP languages, with common features. While both use the term “object,” their implementations and purposes are quite distinct. Yes, they should have used another name, if you ask me.

In Solidity, contracts are objects in the OOP-style, holding internal logic (code), along with data (say, token balance map), and are referenced with a single URI, called “address”. Sui doesn’t behave like this and separates logic and data, each having their own ressource identifier. OMG!

As a result, you have two types of data stored on chain:

  • Modules, which contain code/logic, but no data, as you would see it in a regular contract. Modules are somewhat (but not exactly) similar to libraries in Solidity. They cannot store data by themselves.
  • Objects, which have a unique identified and contain data, but no logic. That said, they are still linked to the module that created them, and manages their lifecycle.

Writing Move code consists therefore in architecting the relationship between “objects” and “modules”. An object, which is a typed piece of data, is created by a module, and can be updated, or deleted, only by interacting with its associated module. Both are referenced using a unique 32 bytes addresses (with a little tweak for objects, as we’ll see in a minute).

Another important consequence of this separation of code and data is that any interaction with a module requires you to provide all of the necessary data, since the module contains none. You can’t pull data from on-chain storage, as you would do in Solidity (checking the state of another contract, for instance) - you need to provide this data directly in the function call.

Wait but if I can pass any data, ahem - I could pass fake data, right?

To avoid this, objects passed as function parameter carry a reference that identifies them in the global storage, and which can be checked for integrity during execution. This reference is designated by two names: key, or UID, which is a wrapper around the object’s address.

Here is an example of an object. An object is just like a classic json object, really, excepts that it contains its own address, typed as UID:

struct DigitalArt has key {
    id: UID, // This is where the key (aka UID) is stored
    artist: address,
    title: vector<u8>,
    price: u64,
}

As we saw, objects can be created and modified by their accompanying module:

module digital_art::gallery {

    // Define an object structure
    struct DigitalArt has key, store {
        id: UID,  // Unique identifier for the object
        artist: address,
        title: vector<u8>,
        price: u64,
    }

    // Function to create a new DigitalArt object
    public fun create_art(
        artist: address, 
        title: vector<u8>, 
        price: u64, 
        ctx: &mut TxContext
    ): DigitalArt {
        DigitalArt {
            // Generate a new UID for the object
            id: object::new(ctx), 
            artist,
            title,
            price,
        }
    }

    // Function to update the price of a DigitalArt object
    public fun update_price(
            art: &mut DigitalArt, 
            new_price: u64
        ) {
        art.price = new_price;
    }
}

Here, the only way to modify the price is to interact with the module. You can see in update_price that we pass the new price along with the full object data, since the module doesn’t contain any data itself. The module can’t pull it from the blockchain either, as we would have done in Solidity, by passing only the UID.

Object ownership

But wait, here, anyone can modify the price? How do you manage access to objects in modules?

This problem is solved by introducing another concept: object ownership. Object can be of either two ownership regime:

Owned object

An owned by a user, or another contract, and can be transfered directly by its owner, without having to interact with its reference module. You can just use the .transfer function.

An owned object may be passed in a module function only by its recorded owner. As a result, in our above case, only the owner can update the price of the DigitalArt object. Another user trying to do it would cause the transaction to revert. Owned objects can also be stored into other objects, which allows interesting use cases, such as composability in defi.

Deep down, the ownership regime for owned objects is linked to the UID. You can’t, for instance, create a new object with a specific UID and expect to transfer it back to you.

Shared objects

Shared objects have their own UID too, but can be used as input in public functions by anyone. This kind of object is great for common data, such as configuration params. Since it is an object, the associated module still controls its lifecyle and who updates it.

To share an object, you need to call transfer::share_object(obj); on it.

Move design pattern : basic admin

What if we wanted that only the gallery owner be able to update the DigitalArt price? We can achieve this by creating a single GalleryOwner object during the module creation, and requiring it in the update_price function:

module digital_art::gallery {

    // ...

    // Define an object for admin functions
    struct GalleryOwnerCap has key, store {
        id: UID
    }
    
    // Equivalent to a constructor
    fun init(ctx: &mut TxContext) {
        let gallery_owner = GalleryOwnerCap { 
            id: object::new(ctx) 
        };
        
        gallery_owner.transfer(tx_context::sender(ctx));
    }

    // ... 

    // Update the price of a DigitalArt object
    public fun update_price(
            _: &GalleryOwnerCap, 
            art: &mut DigitalArt, 
            new_price: u64
        ) {
        art.price = new_price;
    }
}

For those solidity devs who like to use Open Zeppelin’s OnlyAdmin decorator, this is how you achieve the same effect!

Borrowing and Mutations

We saw in the previous section that objects could be owned, but variables also do! đŸ€Ż Let us therefore introduce the concept of variable ownership, and borrowing. This was quite novel for me when I discovered it. Indeed, in Solidity, you can pass variables around, modify state variables directly, and not think too much about who “owns” a particular piece of data at any given time.

There is no such free lunch in Move, which enforces strict rules about who as read and write access to the variables being manipulated during execution. Those restrictions are inherited from Rust, and originate from the need to have a safe execution while allowing parallelization.

Understanding variable ownership

In Move, every value has a single owner, who can manipulate the value at a given time. When you assign a value to a variable, that variable becomes the owner of the value. If you then move that value to another variable or function, the original variable loses ownership and becomes invalid.

This concept is important and you’ll have to think about it constantly, as almost every line of code includes a reference to borrowing. For example:

public struct MyStruct {
    value: u64
}

let x = MyStruct { value: 42 };     // 'x' owns MyStruct
let y = x;      // Ownership of MyStruct moves from 'x' to 'y'
// 'x' is now invalid and cannot be used đŸ˜±

If you try to use x after moving its value to y, the compiler will throw an error because x no longer owns any value. It is possible to just copy a struct, by giving it the copy ability when we define it. More on this later!

Moving vs. borrowing

In Move, you can either move (haha) a value or borrow it:

  • Moving transfers ownership of a value to another variable or function. After the move, the original owner can no longer use the value.
  • Borrowing creates a reference to a value without transferring ownership, allowing you to read or modify the value while the original owner retains ownership.

Wait, wait! Don’t close the page yet! There are two types of borrows:

  • Immutable borrow: &T — a read-only reference to a value of type T. Not modifiable!
  • Mutable borrow: &mut T — a read-write reference to a value of type T.

Here’s an example illustrating moving and borrowing:

struct MyStruct { value: u64 }

fun example() {
    let s = MyStruct { value: 42 };  // 's' owns the value
    let s_ref = &s;                  // Immutable borrow of 's'
    let s_mut_ref = &mut s;          // Mutable borrow of 's'
    let t = s;                       // Move ownership to 't'; 's' is now invalid
}

In this code, after moving s to t, s cannot be used anymore. This ensures that there’s a single owner of the data at any given time.

💡

Why ? This allows to specify strictly who can modify what’s in the memory, preventing data-races and reentrancy. Given that Sui is parallelized, this is an important feature to achieve top blockchain speed safely.

Managing ownership

When designing functions in Move, you need to decide whether a function should take ownership of a value, borrow it immutably, or borrow it mutably:

  • Taking ownership fun foo(bar: T): The function consumes the value, and the caller loses ownership.
  • Immutable borrow fun foo(bar: &T): The function can read the value without modifying it, and the caller retains ownership.
  • Mutable borrow fun foo(bar: &mut T): The function can modify the value, and the caller retains ownership.

Let’s see how it works:

Taking ownership

Suppose we have a DigitalArt object and want to delete it:

public fun delete_art(art: DigitalArt) {
    // 'art' is consumed here; caller loses ownership
    let DigitalArt { id, artist, title, price } = art;
    object::delete(id);
}

Here, the function takes ownership of art. After calling delete_art, the original owner cannot use art anymore.

Borrowing for reading

If we want to display information without modifying the DigitalArt object:

public fun display_art(art: &DigitalArt) {
    // Immutable borrow; caller retains ownership
    let artist = art.artist;
    let title = art.title;
    let price = art.price;
    // Display or return the information
}

Borrowing for mutation

To update the price of the DigitalArt:

public fun update_price(art: &mut DigitalArt, new_price: u64) {
    // Mutable borrow; caller retains ownership
    art.price = new_price;
}

By passing a mutable reference, we can modify art while the caller still owns it.

The holy rules of borrowing and ownership đŸ’«

Move enforces strict rules around ownership and borrowing to ensure safety:

  • Single ownership: Each value has a single owner at any point in time.
  • Unique mutable access: You can have either one mutable reference or multiple immutable references to a value, but not both at the same time.
  • References cannot outlive the owner: A reference cannot be used after the data it points to has been moved or destroyed.

Remember those! As you learn Move, the compiler will yell at you heavily regarding the borrowing issues of your code.

Abilities

In the previous examples, you may have noticed the mention of key, store. Those are the type’s abilities :

struct GalleryOwnerCap has key, store {
    id: UID
}

They define what the object can do, and can be, unburdened by what has been.

Key

The key ability means that the type can have an UID in its field, a reference in the global storage. This means that the data can persist after the function is called.

A catch is that if you only have this ability, the object is non-transferable. You can transfer it to its owner, but then it’s soulbound.

module examples::key_only_object {

    struct KeyOnlyObject has key {
        id: UID
    }

    public fun create_key_only(ctx: &mut TxContext) {
        let obj = KeyOnlyObject {
            id: object::new(ctx)
        };
        // This is valid - 
        // the object will persist and be owned by the sender
        transfer::transfer(obj, tx_context::sender(ctx));
    }

Store

The store ability solves the transfer limitations, by allowing the owner to transfer its asset to other users. It also allows to nest objects into other objects.

module digital_art::gallery {

    // Define an object structure
    struct DigitalArt has key, store {
        id: UID,  // Unique identifier for the object
        artist: address,
        title: vector<u8>,
        price: u64,
    }
    
    struct Gallery has key, store {
        id: UID,
        collection: vector<DigitalArt>
    }

    // ...
    
    // Create a shared gallery object
    public fun create_shared_gallery(ctx: &mut TxContext) {
        let gallery = Gallery {
            id: object::new(ctx),
            collection: vector::empty(),
        };
        transfer::share_object(gallery);
    }
    
    // Add the art to the gallery's collection
    public fun add_to_collection(
            gallery: &mut Gallery, 
            art: DigitalArt
        ) {
        vector::push_back(&mut gallery.collection, art);
    }
}

A type with the store, but no key is commonly used to structure data that will be stored inside objects in a flexible manner.

Copy

The copy ability allows a value to be duplicated without consuming the original. Wait, but what is “consuming” a value, really?

You can consume a value in Move by doing the following:

  • Moving: Transferring ownership of a non-copy value
  • Unpacking: Breaking down a struct into its components
  • Passing by value: Giving a non-copy value to a function

An struct with the copy ability can therefore be copied as much as we want, which can be convenient for things like configuration data, for instance. But can be dangerous if we talk about an NFT, or tokens.

Let’s see how it translates in code:

struct MyCopyableStruct has copy { value: u64 }

let a = MyCopyableStruct { value: 10 };
let b = a; // 'a' is copied to 'b', 'a' is still valid
let c = a; // This is fine because 'a' wasn't consumed

struct NoCopy { value: u64 }

let a = NoCopy { value: 10 };
let b = a;  // 'a' is consumed (moved) here
let c = a;  // Error: 'a' has been consumed

fun take_nocopy(nc: NoCopy) { /* ... */ }
take_nocopy(b);  // 'b' is consumed here
take_nocopy(b);  // Error: 'b' has been consumed

let NoCopy { value } = b;  // 'b' is consumed (unpacked) here

As a result, values with the copy ability can be reused in your code. Which is great!

Why isn’t it allowed by default?

  • Copying large structs can be expensive, so the dev may want to optimize the code.
  • Copy allows you to potentially double-spend asset-like objects!

Why can’t we just use borrowing?

You could indeed copy a struct by creating a new one with the same values, using a reference. This may not be always possible, especially if all of the struct fields aren’t available. This leads also to code with an increased complexity (and more compiler yelling).

Can we just copy objects with an UID? What happens then?

Good question! Seems like an easy way to double spend, right? The answer is that a struct with a key ability cannot be copied, since the unique identifier wouldn’t be unique anymore. Therefore key and copy are incompatible. Sorry!

What if I want to copybut not move the ownership ?

The compiler is usually smart(er than me) about inferring whether an assignment wants to be a copy or a move. You can also play around with forcing this behavior by writing let y = copy x or let y. = move x.

Drop

Let’s talk about the last one - drop. The drop ability allows you to discard or ignore it. How does one discard a type?

If the type doesn’t have a key (and a UID therefore), you can just ignore it and leave it here. If the struct has an UID field and a keyability, you can call object::delete(id);.

struct MyObject has key {
    id: UID
}

public fun delete_object(obj: MyObject) {
    let MyObject { id } = obj;
    object::delete(id);
}

Hot potato

Wait, but what if a type has no ability? What’s the point, right? It’s in this case given the charming name of a hot potato. Such struct must be consumed and can’t be ignored.

This pattern is typically used for flashloans, which expect the money to be returned in the same transaction. Given that Sui Move doesn’t accept raw calldata, as Solidity does, you have to use a Programmable Transaction Block (PTB). A PTB is similar to a flashbot bundle, only that transactions can be “linked” and accept the output of the previous ones. They are executed as one single transaction.

In our context, that means that the hot potato doesn’t have to be consumed in the same function that created it! Nonetheless, only a function of the same module can consume it, creating an interesting limitaton that we can use for flashloans.

Ok, example time! Let’s say that the gallery owner from our previous example wanted to let other users borrow art from the collection to make personal copies. Of course, the art needs to be returned immediately, which is why we’ll use a hot potato.

module digital_art::gallery {

    // Define the DigitalArt struct
    struct DigitalArt has key, store {
        id: UID,  // Unique identifier for the object
        artist: address,
        title: vector<u8>,
        price: u64,
    }

    // Define the Gallery struct
    struct Gallery has key, store {
        id: UID,
        collection: vector<DigitalArt>,
    }

    // Our "hot potato" with no abilities
    struct LoanedDigitalArt {
        art: DigitalArt,
    }

    // Create a new DigitalArt object
    public fun create_digital_art(
        artist: address,
        title: vector<u8>,
        price: u64,
        ctx: &mut TxContext,
    ): DigitalArt {
        DigitalArt {
            id: object::new(ctx), 
            artist,
            title,
            price,
        }
    }

    // Create a new Gallery object
    public fun create_gallery(ctx: &mut TxContext): Gallery {
        Gallery {
            id: object::new(ctx),
            collection: vector::empty(),
        }
    }

    // Add art to the gallery's collection
    public fun add_to_collection(
        gallery: &mut Gallery, 
        art: DigitalArt
    ) {
        vector::push_back(&mut gallery.collection, art);
    }
    // Borrow digital art from the gallery
    public fun borrow_digital_art(
        gallery: &mut Gallery,
        index: u64,
    ): LoanedDigitalArt {
        let art = vector::remove(
            &mut gallery.collection, 
            index
        );
        LoanedDigitalArt { art }
    }

    // Make a copy of the borrowed DigitalArt
    public fun copy_digital_art(
        loaned_art: &LoanedDigitalArt,
        ctx: &mut TxContext,
    ): DigitalArt {
        let original_art = &loaned_art.art;
        DigitalArt {
            id: object::new(ctx),
            artist: original_art.artist,
            title: original_art.title,
            price: original_art.price,
        }
    }

    // Return the borrowed digital art to the gallery
    public fun return_digital_art(
        gallery: &mut Gallery,
        loaned_art: LoanedDigitalArt,
    ) {
        let art = loaned_art.art;
        vector::push_back(&mut gallery.collection, art);
    }
}

And we test:

    #[test]
    public fun test_flashloan_copy_and_return(ctx: &mut TxContext) {
        let mut gallery = Gallery {
            id: object::new(ctx),
            collection: vector::empty(),
        };

        // Create and add a DigitalArt to the gallery
        let art = create_digital_art(
            @0x1,
            b"Sunset Overdrive",
            1000,
            ctx
        );
        add_to_collection(&mut gallery, art);

        // Borrow the DigitalArt from the gallery
        let loaned_art = borrow_digital_art(&mut gallery, 0);

        // Make a copy of the borrowed DigitalArt
        let copied_art = copy_digital_art(&loaned_art, ctx);

        // Return the borrowed DigitalArt to the gallery
        return_digital_art(&mut gallery, loaned_art);

        // At this point, the gallery has two artworks:
        // - The original DigitalArt
        // - The copied DigitalArt with a new UID but same content
    }
}

You may ask - the following while reading this code: but shouldn’t the digital art be returned at the end of the copy_digital_art function? - Yes! But in this case, we only pass a borrowed reference of the digital art, so no need to return the original object.

And if you’re wondering - yes, you can write Move tests in Move, like you would do with Foundry for Solidity.

Types

Move is a typed language (sorry Python devs), inheriting its syntax and overal behavior from Rust. Now the type system may be a bit daunting if you are not comfortable with The Most Admired Language Among Developersℱ, as I was when I started to learn Move.

In Move, you have two kind of types: primitive and generics.

  • Primitive types are the “classic” ones, such as u64, bool, address, etc.
  • Generics are types input provided to ensure type consistency in your code, but not defined.

Wait, what do you mean not defined? Let’s explain.

Generics

If you read the previous parts, you know that in Sui Move code and data is separated. This creates the need to pass all of the data necessary during the function call. As a result, because a module may welcome very different data, it’s necessary to enforce a strong but flexible type system to ensure type consistency throughout the transaction execution.

In practice, generics allow you to pass nested types as inputs for functions, and use them in your code. For instance, if you have a function bake, you would input some Bread<BreadType> and output the same Bread<BreadType>.

Here, the baker has to input Bread but it can be of any kind: Baguette, Bagel, Focaccia, etc. The resulting constraint is that he has to take the same <BreadType> out of the oven.

The generic BreadType is given at the start of the function to allow the compiler to recognize that it is a generic type:

fun bake<GenericType>(...){}

module bakery::bread {
    
    struct Bread<BreadType> has key, store {
        id: UID,
        baked: bool,
        weight: u64,
        bread_type: BreadType
    }

    // Different bread types
    struct Baguette has drop {}
    struct Bagel has drop {}
    struct Focaccia has drop {}

    // Generic function to create unbaked bread
    public fun prepare<BreadType: drop>(
        weight: u64, 
        bread_type: BreadType, 
        ctx: &mut TxContext)
    : Bread<BreadType> {
        return Bread<BreadType> {
            id: object::new(ctx),
            baked: false,
            weight: weight,
            bread_type: bread_type,
        }
    }

    // Generic function to bake bread
    public fun bake<BreadType: drop>(
            bread: &mut Bread<BreadType>
        ): Bread<BreadType> {
        bread.baked = true;
        return bread
    }
}

As you see, the type generic <BreadType> allows us to make sure that the type of the input and output of the function is consistent.

Let’s test:

#[test]
    use std::type_name::{Self, TypeName};

fun test_baking() {
    use sui::test_scenario;

    // Start a test scenario
    let scenario = test_scenario::begin(@0x1);
    let ctx = test_scenario::ctx(&mut scenario);
    let baguette = prepare<Baguette>(250, ctx);
    bake(unbaked_baguette);

    let type_name: TypeName = 
        type_name::get<Bread<Baguette>>();
    let baguette_type_name: TypeName = 
        type_name::get<typeof(baguette)>();

    // Compare the type names
    assert!(type_name == baguette_type_name, 1);
}

In the above code, you may have noticed public fun bake<BreadType: drop>. This is a type constraint, that requires the type input to have the drop ability. It adds a layer of safety to the compiler, who will not execute the transaction if the type doesn’t have the right abilities.

Phantom types

When you define a struct, with a type input which will be recorded nontheless, you don’t need to use the inputed type in the struct’s field. The type is then purely informative, allowing to differenciate structs or enforcing contraints without affecting the struct’s fields.

In this case, you can add the phantom keyword to the type input to specify explicitly that the type is not used in the fields. See the modified example from above:

module bakery::bread {
    
    // We define a phantom type here
    struct Bread<phantom BreadType> has key, store {
        id: UID,
        baked: bool,
        weight: u64,
        // no "BreadType" field!
    }

    // Different bread types
    struct Baguette has drop {}
    struct Bagel has drop {}
    struct Focaccia has drop {}

    // Generic function to create unbaked bread
    public fun prepare<BreadType: drop>(
        weight: u64, 
        breadType: BreadType, 
        ctx: &mut TxContext)
    : Bread<BreadType> {
        return Bread<BreadType> {
            id: object::new(ctx),
            baked: false
            weight: weight
        }
    }

    // Generic function to bake bread
    public fun bake<BreadType: drop>(
            bread: &mut Bread<T>
        ): Bread<T> {
        bread.baked = true;
        return bread
    }
}

We removed the bread_type field from the Bread struct. so now the type input can have the phantom keyword. The Bread struct will still be of type <Bread<BreadType>> if we test it!

Bear in mind that this is optional, but the compiler will yell at you if you don’t use it. Indeed, it supposes that non-phantom types should be used in the struct’s fields, and expects a phantom type if it doesn’t.

Wrapping Up

Diving into Sui Move as a Solidity developer often feels like stepping into an alternate dimension where the rules of physics are slightly different. It’s normal! And we only touched the surface, as there are many more subtle features, and design patterns to discuss. Overall, all of those rules should allow you to write safer code, and let the chain churn transactions at maximum speed.

What should you remember from this article? One of the most important aspect is the separation of code and data in Sui Move. It forces us to think differently about how we structure our programs. Modules act as the logic containers, while objects carry the state, each with their own identifiers.

This separation, while initially confusing, encourages a design that is both modular and clear in terms of data flow and access control. In later blog posts, we’ll discuss possible design patterns to move around it.

In the end, embracing Move’s paradigms can make you a better developer, not just in Sui but in understanding the underlying principles of secure smart contract development. So take your time, experiment with code, and don’t be afraid to make mistakes — the compiler will forgive you and will always love you even if your terminal window overflows with errors.

And who know? You might just find yourself appreciating the strictness of the Move code! (I’m not there yet, though)

Happy coding!

Yakitori.

PS: I would like to thank @KevinAftermath for his advices, and more importantly, his patience when answering the competitively dumb questions I asked as I prepared this article.