Why We Created Sui Move
Sui is the first blockchain to improve on the original Diem design in how it integrates Move, and we share concrete examples of these improvements.
Move was born in 2018 during the early days of the Libra project--two Mysten founders (Evan and myself) were also on the founding team of Libra. Before we made the decision to create a new language, the early Libra team intensely studied existing smart contract use cases and languages to understand what developers wanted to do and where existing languages were not delivering. The key problem we identified is that smart contracts are all about assets and access control, yet early smart contract languages lack type/value representations for both. The Move hypothesis is that if we provide first-class abstractions for these key concepts, we can significantly improve both the safety of smart contracts and the productivity of smart contract programmers—having the right vocabulary for the task at hand changes everything. Over the years, numerous folks contributed to the design and implementation of Move as the language evolved from a key idea into a platform-agnostic smart contract language with the bold goal of becoming the "JavaScript of web3".
Today, we are excited to announce a milestone in our integration of Move into Sui: Sui Move is feature complete, supported by advanced tooling, and has extensive documentation and examples, including:
- A tutorial series on programming with Sui Move objects
- A cookbook of Sui Move basics, design patterns, and samples
- Enhanced VSCode plugin with support for code comprehension and error diagnostics built by the Mysten Move team!
- Integration of Move builds, tests, package management, documentation generation, and the Move Prover with the
sui
CLI - A suite of examples including fungible tokens, NFT’s, DeFi, and games.
When we started working on Sui in late 2021, we took a fresh look at Move and reflected both on which early design decisions have not aged well and on how Move could be improved to leverage Sui’s unique features. We have previously written about what’s new in Sui Move at the language level, but we haven’t gone into depth on the motivation for introducing these differences. The remainder of this post covers this inquiry in detail in an example-driven fashion.
Wait, there are different Move’s?
Move is a cross-platform, embedded language. The core language itself is very simple: it has generic concepts, such as structs, integers, and addresses, but it does not have blockchain-specific concepts like accounts, transactions, time, cryptography, etc. These features must be provided by the blockchain platform that integrates Move. Importantly, these blockchains do not need their own fork of Move—each platform uses the same Move VM, bytecode verifier, compiler, prover, package manager, and CLI, but adds blockchain-specific features via code that builds on top of these core components.
Diem was the first blockchain to embed Move, and subsequent Move-based chains (including 0L, StarCoin, and Aptos) have largely used the Diem-style approach. Although Diem-style Move has some nice qualities, both the permissioned nature of Diem and certain implementation details of the Diem blockchain (particularly the storage model) make the implementation of some fundamental smart contract use cases difficult. In particular, the original designs of Move and Diem predated the popularity explosion of NFTs and have some quirks that make NFT-related use cases especially tricky to implement.
In this post, we will walk through three such examples that showcase a problem with the original Diem-style Move embedding and depict how we have addressed this problem in Sui Move. We assume some basic understanding of Move, but hopefully, the key points will be understandable to anyone with a programming background.
Frictionless mass asset creation
The ability to create and distribute assets in bulk is crucially important for both onboarding and engaging web3 users. Perhaps a Twitch streamer wants to distribute commemorative NFTs, a creator wants to send out tickets for a special event, or a game developer wants to airdrop new items to all of its players.
Here is a (failed) attempt to write code for mass minting of an asset in Diem-style Move. This code takes a vector of recipient addresses as input, generates an asset for each one, and attempts to transfer the asset.
struct CoolAsset { id: GUID, creation_date: u64 } has key, store
public entry fun mass_mint(creator: &signer, recipients: vector<address>) {
assert!(signer::address_of(creator) == CREATOR, EAuthFail);
let i = 0;
while (!vector::is_empty(recipients)) {
let recipient = vector::pop_back(&mut recipients);
assert!(exists<Account>(recipient), ENoAccountAtAddress);
let id = guid::create(creator);
let creation_date = timestamp::today();
// error! recipient must be `&signer`, not `address`
move_to(recipient, CoolAsset { id, creation_date })
}
In Diem-style Move, the global storage is keyed by (address, type name) pairs—that is, every address can store at most one asset of a given type. Thus, the line move_to(recipient, CoolAsset { ... }
is attempting to transfer the CoolAsset
by storing it under the recipient
address.
However, this code will fail to compile at the line move_to(recipient, …)
. The key problem is that in Diem-style Move, you cannot send a value of type CoolAsset
to an address A unless:
- A non-A address sends a transaction to create an account at A
- The owner of A sends a transaction to explicitly opt-in to receiving objects of type
CoolAsset
That’s two transactions just to receive an asset! The decision to do things this way made sense for Diem, which was a permissioned system that needed to carefully restrict account creation and prevent accounts from holding too many assets due to limitations in the storage system. But this is extremely restrictive for an open system that wants to use asset distribution as onboarding mechanism, or just generally allow assets to flow freely between users as they do on Ethereum and similar blockchains [1].
Now, let’s look at the same code in Sui Move:
struct CoolAsset { id: VersionedID, creation_date: u64 } has key
public entry fun mass_mint(recipients: vector<address>, ctx: &mut TxContext) {
assert!(tx_context::sender(ctx) == CREATOR, EAuthFail);
let i = 0;
while (!vector::is_empty(recipients)) {
let recipient = vector::pop_back(&mut recipients);
let id = tx_context::new_id(ctx);
let creation_date = tx_context::epoch(); // Sui epochs are 24 hours
transfer(CoolAsset { id, creation_date }, recipient)
}
}
Sui Move’s global storage keyed by object ID’s. Every struct with the key
ability is a “Sui object” that must have a globally unique id
field. Instead of using the restricted move_to
construct, Sui Move introduces a transfer
primitive that can be used on any Sui object. Under the hood, this primitive maps id
to CoolAsset
in the global storage and adds metadata to indicate the value is owned by recipient
.
An interesting property of the Sui version of mass_mint
is that it commutes with all other transactions (including others that call mass_mint
!). The Sui runtime will notice this and send transactions that call this function through the Byzantine consistent broadcast “fast path” that doesn’t need consensus. Such transactions can be both committed and executed in parallel! This requires no effort from the programmer—they just write the code above and the runtime takes care of the rest.
Perhaps subtly, this is not true of the Diem variant of this code—even if the code above worked, both the exists<Account>
and guid::create
calls would create points of contention with other transactions generating GUID
s or touching the Account
resource. In some cases, it’s possible to rewrite Diem-style Move code to avoid points of contention, but many of the idiomatic ways of writing Diem-style Move introduce subtle bottlenecks that frustrate parallel execution.
Native asset ownership and transfers
Let’s extend the Diem-style Move code with a workaround that will actually compile and run. The idiomatic way to do this is the “wrapper pattern”: because Bob can’t directly move_to
a CoolAsset
to Alice’s address, we ask Alice to “opt in” to receiving CoolAsset
's by first publishing a wrapper type CoolAssetStore
with a collection type (Table
) inside. Alice can do this by calling the opt_in
function. We then add code that allows Bob to move a CoolAsset
from his CoolAssetStore
into Alice’s CoolAssetStore
.
In this code, let’s add one other wrinkle: we will only allow CoolAsset
's to be transferred if it has been at least 30 days since they were created. This sort of policy is important for creators that (e.g.) want to discourage speculators from purchasing/flipping event tickets so it is easier for true fans to get them at a reasonable price.
struct CoolAssetStore has key {
assets: Table<TokenId, CoolAsset>
}
public fun opt_in(addr: &signer) {
move_to(addr, CoolAssetHolder { assets: table::new() }
}
public entry fun cool_transfer(
addr: &signer, recipient: address, id: TokenId
) acquires CoolAssetStore {
// withdraw
let sender = signer::address_of(addr);
assert!(exists<CoolAssetStore>(sender), ETokenStoreNotPublished);
let sender_assets = &mut borrow_global_mut<CoolAssetStore>(sender).assets;
assert!(table::contains(sender_assets, id), ETokenNotFound);
let asset = table::remove(&sender_assets, id);
// check that 30 days have elapsed
assert!(time::today() > asset.creation_date + 30, ECantTransferYet)
// deposit
assert!(exists<CoolAssetStore>(recipient), ETokenStoreNotPublished);
let recipient_assets = &mut borrow_global_mut<CoolAssetStore>(recipient).assets;
assert!(table::contains(recipient_assets, id), ETokenIdAlreadyUsed);
table::add(recipient_assets, asset)
}
This code works. But it’s a pretty complex way to accomplish the simple goal of transferring an asset from Alice to Bob! Again, let’s look at the Sui Move variant:
public entry fun cool_transfer(
asset: CoolAsset, recipient: address, ctx: &mut TxContext
) {
assert!(tx_context::epoch(ctx) > asset.creation_date + 30, ECantTransferYet);
transfer(asset, recipient)
}
This code is much shorter. The key thing to notice here is that cool_transfer
is an entry
function (meaning it can be called directly by the Sui runtime via a transaction), yet it has a parameter of type CoolAsset
as an input. This is Sui runtime magic at work again! A transaction includes the set of object ID’s it wants to operate on, and the Sui runtime:
- Resolves the ID’s to object values (removing the need for the
borrow_global_mut
andtable_remove
parts in the Diem-style code above) - Checks that the object is owned by the sender of the transaction (removing the need for the
signer::address_of
part + associated code above). This part is especially interesting, as we will explain shortly: in Sui, safe object ownership ownership checks are part of the runtime! - Checks the types of the object values against the parameter types of the invoked function
cool_transfer
- Binds the object values and other arguments to the parameters of
cool_transfer
and invokes the function
This allows the Sui Move programmer to skip the boilerplate of the “withdraw” part of the logic and jump straight to the interesting part: checking the 30-day expiration policy. Similarly, the “deposit” part is vastly simplified via the Sui Move transfer
construct explained above. And finally, there’s no need to introduce a wrapper type like CoolAssetStore
with an internal collection—the id-indexed Sui global storage allows an address to store an arbitrary number of values with a given type.
One other difference to point out is that there are 5 ways the Diem-style cool_transfer
can abort (i.e., fail and charge the user for gas without completing the transfer), whereas the Sui Move cool_transfer
can only abort in 1 way: when the 30-day policy is violated.
Offloading object ownership checks to the runtime is a big win not only in terms of ergonomics, but also in terms of safety. Safe implementations of this at the runtime level prevents the mistakes of implementing these checks (or forgetting them entirely!) by construction.
Finally, notice how the Sui Move entry point function signature cool_transfer( asset: CoolAsset, ...)
gives us a lot of information about what the function is going to do (in contrast to the Diem-style function signature, which is more opaque). We can think of this function as asking permission to transfer CoolAsset
, whereas a different function f(asset: &mut CoolAsset, ...)
is asking for permission to write (but not transfer) CoolAsset
, and g(asset: &CoolAsset, ...)
is only asking for read permissions.
Because this information is available directly in the function signature (no execution or static analysis required!), it can be used directly by the wallet and other client tooling. In the Sui wallet, we are working on human-readable signing requests that leverage these structured function signatures to provide an iOS/Android-style permissions prompt to the user. The wallet can say something like “This transaction is asking for permission read your CoolAsset
, write your AssetCollection
, and transfer your ConcertTicket
. Proceed?”.
Human-readable signing requests address a massive attack vector present on many existing platforms (including ones using Diem-style Move!), where wallet users must blindly sign transactions without understanding what their effects might be. We think making the wallet experience less dangerous is a key step for promoting mainstream adoption of crypto wallets, and have designed Sui Move to support this goal by enabling features like human-readable signing requests.
Bundling heterogenous assets
Finally, let’s consider an example concerning bundling assets of different types. This is a fairly common use-case: a programmer might might want to package NFT’s of different types in a collection, bundle items to be sold together in a marketplace, or add accessories to an existing item. Concretely, let’s say we have to following scenario:
- Alice has defined a
Character
object to be used in a game - Alice wants to support accessorizing her character with third-party accessories of different types created later
- Anyone should be able to create an accessory, but the owner of a
Character
should decide whether to add an accessory or not. - Transferring a
Character
should automatically transfer all of its accessories.
This time, let’s start with the Sui Move code. We will take advantage of another facet of the Sui runtime’s built-in object ownership feature: an object can be owned by another object. Every object has a unique owner, but a parent object can have an arbitrary number of child objects. Parent/child object relationships are created by using the transfer_to_object
function, a sibling of the transfer
function introduced above.
// in the Character module, created by Alice
struct Character has key {
id: VersionedID,
favorite_color: u8,
strength: u64,
...
}
/// The owner of `c` can choose to add `accessory`
public entry fun accessorize<T: key>(c: &mut Character, accessory: T) {
transfer_to_object(c, accessory)
}
// ... in a module added later by Bob
struct SpecialShirt has key {
id: VersionedID,
color: u8
}
public entry fun dress(c: &mut Character, s: Shirt) {
// a special shirt has to be the character's favorite color
assert!(character::favorite_color(c) == s.color, EBadColor);
character::accessorize(c, shirt)
}
// ... in a module added later by Clarissa
struct Sword has key {
id: VersionedID,
power: u64
}
public entry fun equip(c: &mut Character, s: Sword) {
// a character must be very strong to use a powerful sword
assert!(character::strength(c) > sword.power * 2, ENotStrongEnough);
character::accessorize(c, s)
}
In this code, the Character
module includes an accessorize
function that lets the owner of a character add an accessory object with an arbitrary type as a child object. This allows Bob and Clarissa to create their own accessory types with different attributes and functionality that were not anticipated by Alice, yet build on what Alice has already done. For example, Bob’s shirt can only be equipped if it is the character’s favorite color, and Clarissa’s sword can only be used if the character is strong enough to wield it.
In Diem-style Move, it is not possible to implement this sort of scenario. Here are a few attempts at implementation strategies that fall short:
// attempt 1
struct Character {
// won't work because every Accessory would need to be the same type + have
// the same fields. There is no subtyping in Move.
// Bob's shirt needs a color, and Clarissa's sword needs power--no standard
// representation of Accessory can anticipate everything devs will want to
// create
accessories: vector<Accessory>
}
// attempt 2
struct Character {
// perhaps Alice anticipates the need for a Sword and a Shirt up front...
sword: Option<Sword>,
shirt: Option<Shirt>
// ...but what happens when Daniel comes along later and wants to add Pants?
}
// attempt 3
// Does not support accessory compositions. For example: how do we represent a
// Character with Pants and a Shirt, but no Sword?
struct Shirt { c: Character }
struct Sword { s: Shirt }
struct Pants { s: Sword }
The key problems are that in Diem-style Move:
- Only homogenous collections are supported (as the first attempt demonstrates), but accessories are fundamentally heterogenous
- Associations between objects can be created only via “wrapping” (i.e., storing an object inside another object); but the set of objects that can be wrapped must be defined up-front (as in the second attempt) or added in an ad-hoc fashion that does not support accessory composition (as in the third attempt)
Conclusion
Sui is the first platform to diverge substantially from the original Diem design in how it uses Move. Designing embeddings that fully leverage Move and the unique features of the platform is both an art and a science that requires a deep understanding of both the Move language and the underlying blockchain’s capabilities. We’re really excited about the advances Sui Move has made and the new use cases it will enable!
[1] Another argument made in favor of the Diem-style Move policy of “must opt-in to receive an asset of a given type” is that it is a good mechanism for spam prevention. However, we think spam prevention belongs to the application layer. Rather than asking users to send transactions that cost real money to opt in to receiving an asset, spam is easily addressed at (e.g.) the wallet level with rich user-defined policies and the aid of automated spam filters.