title |
---|
Chapter 1 - Object Basics |
In Move, besides primitive data types, we can define organized data structures using struct
. For example:
struct Color {
red: u8,
green: u8,
blue: u8,
}
The above struct
defines a data structure that can represent RGB color. struct
s like this can be used to organize data with complicated semantics. However, instances of struct
s like Color
are not Sui objects yet.
To define a struct that represents a Sui object type, we must add a key
capability to the definition, and the first field of the struct must be the id
of the object with type UID
from the object module:
use sui::object::UID;
struct ColorObject has key {
id: UID,
red: u8,
green: u8,
blue: u8,
}
Now ColorObject
represents a Sui object type and can be used to create Sui objects that can be eventually stored on the Sui chain.
📚 In both core Move and Sui Move, the key ability denotes a type that can appear as a key in global storage. However, the structure of global storage is a bit different: core Move uses a (type,
address
)-indexed map, whereas Sui Move uses a map keyed by object IDs.
💡 The
UID
type is internal to Sui, and you most likely won't need to deal with it directly. For curious readers, it contains the "unique ID" that defines an object. It is unique in the sense that no two values of typeUID
will ever have the same underlying set of bytes.
Now that we have learned how to define a Sui object type, how do we create/instantiate a Sui object? In order to create a new Sui object from its type, we must assign an initial value to each of the fields, including id
. The only way to create a new UID
for a Sui object is to call object::new
. The new
function takes the current transaction context as an argument to generate unique ID
s. The transaction context is of type &mut TxContext
and should be passed down from an entry function (a function that can be called directly from a transaction). Let's look at how we may define a constructor for ColorObject
:
// object creates an alias to the object module, which allows us call
// functions in the module, such as the `new` function, without fully
// qualifying, e.g. `sui::object::new`.
use sui::object;
// tx_context::TxContext creates an alias to the the TxContext struct in tx_context module.
use sui::tx_context::TxContext;
fun new(red: u8, green: u8, blue: u8, ctx: &mut TxContext): ColorObject {
ColorObject {
id: object::new(ctx),
red,
green,
blue,
}
}
💡 Move supports field punning, which allows us to skip the field values if the field name happens to be the same as the name of the value variable it is bound to. The code above leverages this to write "
red,
" as shorthand for "red: red,
".
We have defined a constructor for the ColorObject
. Calling this constructor will put the value in a local variable where it can be returned from the current function, passed to other functions, or stored inside another struct. And of course, the object can be placed in persistent global storage so it can be read by the outside world and accessed in subsequent transactions.
All of the APIs for adding objects to persistent storage live in the transfer
module. One key API is:
public fun transfer<T: key>(obj: T, recipient: address)
This places obj
in global storage along with metadata that records recipient
as the owner of the object. In Sui, every object must have an owner, which can be either an address, another object, or "shared"--see object ownership for more details.
💡 In core Move, we would call
move_to<T>(a: address, t: T)
to add the entry(a, T) -> t
to the global storage. But because (as explained above) the schema of Sui Move's global storage is different, we use theTransfer
APIs instead ofmove_to
or the other global storage operators in core Move. These operators cannot be used in Sui Move.
A common use of this API is to transfer the object to the sender/signer of the current transaction (e.g., mint an NFT owned by you). The only way to obtain the sender of the current transaction is to rely on the transaction context passed in from an entry function. The last argument to an entry function must be the current transaction context, defined as ctx: &mut TxContext
.
To obtain the current signer's address, one can call tx_context::sender(ctx)
.
Below is the code that creates a new ColorObject
and makes it owned by the sender of the transaction:
use sui::transfer;
// This is an entry function that can be called directly by a Transaction.
public entry fun create(red: u8, green: u8, blue: u8, ctx: &mut TxContext) {
let color_object = new(red, green, blue, ctx);
transfer::transfer(color_object, tx_context::sender(ctx))
}
💡 Naming convention: Constructors are typically named
new
, which returns an instance of the struct type. Thecreate
function is typically defined as an entry function that constructs the struct and transfers it to the desired owner (most commonly the sender).
We can also add a getter to ColorObject
that returns the color values so that modules outside of ColorObject
are able to read their values:
public fun get_color(self: &ColorObject): (u8, u8, u8) {
(self.red, self.green, self.blue)
}
Find the full code online in color_object.move.
To compile the code, make sure you have installed Sui so that sui
is in PATH
. In the code root directory (where Move.toml
is), run:
sui move build
After defining the create
function, we want to test this function in Move using unit tests, without having to go all the way through sending Sui transactions. Since Sui manages global storage separately outside of Move, there is no direct way to retrieve objects from global storage within Move. This poses a question: after calling the create
function, how do we check that the object is properly transferred?
To assist easy testing in Move, we provide a comprehensive testing framework in the test_scenario module that allows us to interact with objects put into the global storage. This allows us to test the behavior of any function directly in Move unit tests. A lot of this is also covered in our Move testing doc.
The idea of test_scenario
is to emulate a series of Sui transactions, each sent from a particular address. A developer writing a test starts the first transaction using the test_scenario::begin
function that takes the address of the user sending this transaction as an argument and returns an instance of the Scenario
struct representing a test scenario.
An instance of the Scenario
struct contains a per-address object pool emulating Sui's object storage, with helper functions provided to manipulate objects in the pool. Once the first transaction is finished, subsequent transactions can be started using the test_scenario::next_tx
function that takes an instance of the Scenario
struct representing the current scenario and an address of a (new) user as arguments.
Now let's try to write a test for the create
function. Tests that need to use test_scenario
must be in a separate module, either under a tests
directory, or in the same file but in a module annotated with #[test_only]
. This is because test_scenario
itself is a test-only module, and can be used only by test-only modules.
First of all, we begin the test with a hardcoded test address, which will also give us a transaction context as if we are sending the transaction started with test_scenario::begin
from this address. We then call the create
function, which should create a ColorObject
and transfer it to the test address:
let owner = @0x1;
// Create a ColorObject and transfer it to @owner.
let scenario = &mut test_scenario::begin(&owner);
{
let ctx = test_scenario::ctx(scenario);
color_object::create(255, 0, 255, ctx);
};
📚 Note there is a "
;
" after "}
".;
is required to sequence a series of expressions, and even the block{ ... }
is an expression! Refer to the Move book for a detailed explanation.
Now, after the first transaction completes (and only after the first transaction completes), address @0x1
should own the object. Let's first make sure it's not owned by anyone else:
let not_owner = @0x2;
// Check that not_owner does not own the just-created ColorObject.
test_scenario::next_tx(scenario, ¬_owner);
{
assert!(!test_scenario::can_take_owned<ColorObject>(scenario), 0);
};
test_scenario::next_tx
switches the transaction sender to @0x2
, which is a new address different from the previous one.
test_scenario::can_take_owned
checks whether an object with the given type actually exists in the global storage owned by the current sender of the transaction. In this code, we assert that we should not be able to remove such an object, because @0x2
does not own any object.
💡 The second parameter of
assert!
is the error code. In non-test code, we usually define a list of dedicated error code constants for each type of error that could happen in production. For unit tests though, it's usually unnecessary because there will be way too many assetions and the stacktrace upon error is sufficient to tell where the error happened. Hence we recommend just putting0
there in unit tests for assertions.
Finally we check that @0x1
owns the object and the object value is consistent:
test_scenario::next_tx(scenario, &owner);
{
let object = test_scenario::take_owned<ColorObject>(scenario);
let (red, green, blue) = color_object::get_color(&object);
assert!(red == 255 && green == 0 && blue == 255, 0);
test_scenario::return_owned(scenario, object);
};
test_scenario::take_owned
removes the object of given type from global storage that's owned by the current transaction sender (it also implicitly checks can_take_owned
). If this line of code succeeds, it means that owner
indeed owns an object of type ColorObject
.
We also check that the field values of the object match with what we set in creation. At the end, we must return the object back to the global storage by calling test_scenario::return_owned
so that it's back to the global storage. This also ensures that if any mutations happened to the object during the test, the global storage is aware of the changes.
Again, you can find the full code in color_object.move.
To run the test, simply run the following in the code root directory:
sui move test
Now let's try to call create
in actual transactions and see what happens. To do this, we need to start Sui and the CLI client. Follow the Sui CLI client guide to start the Sui network and set up the client.
Before starting, let's take a look at the default client address (this is the address that will eventually own the object later):
$ sui client active-address
This will tell you the current client address.
First, we need to publish the code on-chain. Assuming the path to the root of the repository containing Sui source code is $ROOT:
$ sui client publish --path $ROOT/sui_programmability/examples/objects_tutorial --gas-budget 10000
You can find the published package object ID in the Publish Results output:
----- Publish Results ----
The newly published package object: (0x57258f32746fd1443f2a077c0c6ec03282087c19, SequenceNumber(1), o#b3a8e284dea7482891768e166e4cd16f9749e0fa90eeb0834189016c42327401)
Note that the exact data you see will be different. The first hex string in that triple is the package object ID (0x57258f32746fd1443f2a077c0c6ec03282087c19
in this case). For convenience, let's save it to an environment variable:
$ export PACKAGE=0x57258f32746fd1443f2a077c0c6ec03282087c19
Next we can call the function to create a color object:
$ sui client call --gas-budget 1000 --package $PACKAGE --module "color_object" --function "create" --args 0 255 0
In the Transaction Effects portion of the output, you will see an object showing up in the list of Created Objects, like this:
Created Objects:
0x5eb2c3e55693282faa7f5b07ce1c4803e6fdc1bb SequenceNumber(1) o#691b417670979c6c192bdfd643630a125961c71c841a6c7d973cf9429c792efa
Again, for convenience, let's save the object ID:
$ export OBJECT=0x5eb2c3e55693282faa7f5b07ce1c4803e6fdc1bb
We can inspect this object and see what kind of object it is:
$ sui client object --id $OBJECT
This will show you the metadata of the object with its type:
Owner: AddressOwner(k#5db53ebb05fd3ea5f1d163d9d487ee8cd7b591ee)
Version: 1
ID: 0x5eb2c3e55693282faa7f5b07ce1c4803e6fdc1bb
Readonly: false
Type: 0x57258f32746fd1443f2a077c0c6ec03282087c19::color_object::ColorObject
As we can see, it's owned by the current default client address that we saw earlier. And the type of this object is ColorObject
!
You can also look at the data content of the object by adding the --json
parameter:
$ sui client object --id $OBJECT --json
This will print the values of all the fields in the Move object, such as the values of red
, green
, and blue
.
Congratulations! You have learned how to define, create, and transfer objects. You should also know how to write unit tests to mock transactions and interact with the objects. In the next chapter, we will learn how to use the objects that we own.