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 typeT
. Not modifiable! - Mutable borrow:
&mut T
â a read-write reference to a value of typeT
.
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
copy
but 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 key
ability, 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.