title |
---|
Chapter 5 - Child Objects |
In the previous chapter, we walked through various ways of wrapping an object in another object. There are a few limitations in object wrapping:
- A wrapped object can be accessed only via its wrapper. It cannot be used directly in a transaction or queried by its ID (e.g., in the explorer).
- An object can become very large if it wraps several other objects. Larger objects can lead to higher gas fees in transactions. In addition, there is an upper bound on object size.
- As we will see in future chapters when we introduce the
Bag
library, there will be use cases where we need to store a collection of objects of heterogeneous types. Since the Movevector
type must be templated on one single typeT
, it is not suitable for this.
Fortunately, Sui provides another way to represent object relationships: an object can own other objects. In the first chapter, we introduced libraries for transferring objects to an account address. In this chapter, we will introduce libraries that allow you transfer objects to other objects.
There are two ways of creating child objects which we describe in the following sections.
Assume we own two objects in our account address. To make one object own the other object, we can use the following API in the transfer
library:
public fun transfer_to_object<T: key, R: key>(
obj: T,
owner: &mut R,
): ChildRef<T>;
The first argument obj
will become a child object of the second argument owner
. obj
must be passed by value, i.e. it will be fully consumed and cannot be accessed again within the same transaction (similar to transfer
function). After calling this function, the on-chain owner metadata of obj
will change to the ID of the owner
object.
The function returns a special struct ChildRef<T>
where T
matches the type of the child object. It represents a reference to the child object. Since ChildRef
is a struct type without drop
ability, Move ensures the return value cannot be dropped. This ensures the caller of the function must put the reference somewhere and cannot forget about it.
This is very important because later on if we attempt to delete the parent object, the existence of the child references forces us to take care of them. Otherwise, we may end up in a situation where we deleted the parent object, but there are still some child objects; and these child objects will be locked forever, as we will explain in latter sections. In the last section, we will also see how this reference is used to move around child objects and to prevent making mistakes.
Let's look at some code. The full source code can be found in object_owner.move.
First we define two object types for the parent and the child:
struct Parent has key {
id: VersionedID,
child: Option<ChildRef<Child>>,
}
struct Child has key {
id: VersionedID,
}
Parent
type contains a child
field that is an optional child reference to an object of Child
type.
First we define an API to create an object of Child
type:
public entry fun create_child(ctx: &mut TxContext) {
transfer::transfer(
Child { id: tx_context::new_id(ctx) },
tx_context::sender(ctx),
);
}
The above function creates a new object of Child
type and transfers it to the sender account address of the transaction, i.e. after this call, the sender account owns the object.
Similarly, we can define an API to create an object of Parent
type:
public entry fun create_parent(ctx: &mut TxContext) {
let parent = Parent {
id: tx_context::new_id(ctx),
child: option::none(),
};
transfer::transfer(parent, tx_context::sender(ctx));
}
Since the child
field is Option
type, we can start with Option::none()
.
Now we can define an API that makes an object of Child
a child of an object of Parent
:
public entry fun add_child(parent: &mut Parent, child: Child) {
let child_ref = transfer::transfer_to_object(child, parent);
option::fill(&mut parent.child, child_ref);
}
This function takes child
by value, calls transfer_to_object
to transfer the child
object to the parent
, and returns a child_ref
.
After that, we can fill the child
field of parent
with child_ref
.
If we comment out the second line, the Move compiler will complain that we cannot drop child_ref
.
At the end of the add_child
call, we have the following ownership relationship:
- Sender account address owns a
Parent
object. - The
Parent
object owns aChild
object.
In the above example, Parent
has an optional child field. What if the field is not optional? We must construct Parent
with a ChildRef
. However, in order to have a ChildRef
, we have to transfer the child object to the parent object first. This creates a paradox. We cannot create the parent unless we have a ChildRef
, and we cannot have a ChildRef
unless we already have the parent object. To solve this exact problem and be able to construct a non-optional ChildRef
field, we provide another API that allows you to transfer an object to object ID, instead of to object:
public fun transfer_to_object_id<T: key>(
obj: T,
owner_id: VersionedID,
): (VersionedID, ChildRef<T>);
To use this API, we don't need to create a parent object yet; we need only the object ID of the parent object, which can be created in advance through tx_context::new_id(ctx)
. The function returns a tuple: it will return the owner_id
that was passed in, along with the ChildRef
representing a reference to the child object obj
. It may seem strange that we require passing in owner_id
by value only to return it. This is to ensure that the caller of the function does indeed own a VersionedID
that hasn't been used in any object yet. Without this, it can be easy to make mistakes.
Let's see how this is used in action. First we define another object type that has a non-optional child field:
struct AnotherParent has key {
id: VersionedID,
child: ChildRef<Child>,
}
And let's see how we define the API to create AnotherParent
instance:
public entry fun create_another_parent(child: Child, ctx: &mut TxContext) {
let id = tx_context::new_id(ctx);
let (id, child_ref) = transfer::transfer_to_object_id(child, id);
let parent = AnotherParent {
id,
child: child_ref,
};
transfer::transfer(parent, tx_context::sender(ctx));
}
In the above function, we need to first create the ID of the new parent object. With the ID, we can then transfer the child object to it by calling transfer_to_object_id
, thereby obtaining a reference child_ref
. With both id
and child_ref
, we can create an object of AnotherParent
, which we would eventually transfer to the sender's account.
We have explained in the first chapter that, in order to use an owned object, the object owner must be the transaction sender. What about objects owned by objects? We require that the object's owner object must also be passed as an argument in the Move call. For example, if object A owns object B, and object B owns object C, to be able to use C when calling a Move entry function, one must also pass B as an argument; and since B is an argument, A must also be an argument. This essentially means that to use an object, its entire ownership ancestor chain must be included, and the account owner of the root ancestor must match the sender of the transaction.
Let's look at how we could use the child object created earlier. Let's define two entry functions:
public entry fun mutate_child(_child: &mut Child) {}
public entry fun mutate_child_with_parent(_child: &mut Child, _parent: &mut Parent) {}
The first function requires only one object argument, which is a Child
object. The second function requires two arguments, a Child
object and a Parent
object. Both functions are made empty since what we care about here is not the mutation logic, but whether you are able to make a call to them at all.
Both functions will compile successfully, because object ownership relationships are dynamic properties and the compiler cannot forsee them.
Let's try to interact with these two entry functions on-chain and see what happens. First we publish the sample code:
$ wallet publish --path sui_core/src/unit_tests/data/object_owner --gas-budget 5000
----- Publish Results ----
The newly published package object ID: 0x3cfcee192b2fbafbce74a211e40eaf9e4cb746b9
Then we create a child object:
$ export PKG=0x3cfcee192b2fbafbce74a211e40eaf9e4cb746b9
$ wallet call --package $PKG --module object_owner --function create_child --gas-budget 1000
----- Transaction Effects ----
Created Objects:
- ID: 0xb41d157fdeda968c5b5f0d8b87b6ebb84d7d1941 , Owner: Account Address ( 0x5f67488c28c46e56bcefb808ae499ef323c1236d )
At this point we only created the child object, but it's still owned by an account address. We can verify that we should be able to call mutate_child
function by only passing in the child object:
$ export CHILD=0xb41d157fdeda968c5b5f0d8b87b6ebb84d7d1941
$ wallet call --package $PKG --module object_owner --function mutate_child --args $CHILD --gas-budget 1000
----- Transaction Effects ----
Status : Success
Mutated Objects:
- ID: 0xb41d157fdeda968c5b5f0d8b87b6ebb84d7d1941
Indeed the transasaction succeeded.
Now let's create the Parent
object as well:
$ wallet call --package $PKG --module object_owner --function create_parent --gas-budget 1000
----- Transaction Effects ----
Created Objects:
- ID: 0x2f893c18241cfbcd390875f6e1566f4db949392e
Now we can make the parent object own the child object:
$ export PARENT=0x2f893c18241cfbcd390875f6e1566f4db949392e
$ wallet call --package $PKG --module object_owner --function add_child --args $PARENT $CHILD --gas-budget 1000
----- Transaction Effects ----
Mutated Objects:
- ID: 0xb41d157fdeda968c5b5f0d8b87b6ebb84d7d1941 , Owner: Object ID: ( 0x2f893c18241cfbcd390875f6e1566f4db949392e )
As we can see, the owner of the child object has been changed to the parent object ID.
Now if we try to call mutate_child
again, we will see an error:
$ wallet call --package $PKG --module object_owner --function mutate_child --args $CHILD --gas-budget 1000
Object 0xb41d157fdeda968c5b5f0d8b87b6ebb84d7d1941 is owned by object 0x2f893c18241cfbcd390875f6e1566f4db949392e, which is not in the input
To be able to mutate the child object, we must also pass the parent object as argument. Hence we need to call the mutate_child_with_parent
function:
$ wallet call --package $PKG --module object_owner --function mutate_child_with_parent --args $CHILD $PARENT --gas-budget 1000
It will finish successfully.
In this section, we will introduce a few more APIs that will allow us safely move around child objects.
There are two ways to transfer a child object:
- Transfer it to an account address, thus it will no longer be a child object after the transfer.
- Transfer it to another object, thus it will still be a child object but with the parent object changed.
First of all, let's look at how to transfer a child object to an account address. The Transfer library provides the following API:
public fun transfer_child_to_address<T: key>(
child: T,
child_ref: ChildRef<T>,
recipient: address,
);
transfer_child_to_address
transfers an object that is currently a child to an account address. This function takes 3 arguments: child
is the child object we wish to transfer, child_ref
is the reference to it that was obtained when we previously transferred it to its current parent, and recipient
is the recipient account address. After the transfer, the recipient
account address now owns this object.
There are two important things worth mentioning:
- Requiring
child_ref
as an argument ensures that the old parent won't have an out-of-date reference to the child object, and this reference is properly destroyed by the library during the transfer. - This function has no return value. We no longer need a
ChildRef
because the object is no longer a child object.
To demonstrate how to use this API, let's implement a function that removes a child object from a parent object and transfer it back to the account owner:
public entry fun remove_child(parent: &mut Parent, child: Child, ctx: &mut TxContext) {
let child_ref = option::extract(&mut parent.child);
transfer::transfer_child_to_address(child, child_ref, tx_context::sender(ctx));
}
In the above function, the reference to the child is extracted from the parent
object, which is then passed together with the child
object to the transfer_child_to_address
, with recipient as the sender of the transaction. It is important to note that this function must also take the child
object as an argument. Move is not able to obtain the child object only from the reference. An object must always be explicitly provided in the transaction to make the transfer work. As we explained earlier, the fact that transfer_child_to_address
requires the child reference as an argument guarantees that the parent
object no longer holds a reference to the child object.
Another way to transfer a child object is to transfer it to another parent. The API is also defined in the Transfer library:
public fun transfer_child_to_object<T: key, R: key>(
child: T,
child_ref: ChildRef<T>,
owner: &mut R,
): ChildRef<T>;
After this call, the object child
will become a child object of the object owner
.
Comparing to the previous API, there are two primary differences:
- Instead of transferring the object to an recipient address, here we need to provide a mutable reference to the new parent object
owner
. Although we are not mutating the new parent object in practice, we requiremut
to make sure the new owner is not an immutable object: child objects cannot be added to an immutable object. - The function returns a new
ChildRef
. This is because we are transferring this object to a new parent, and hence a new reference is created to represent this child relationship, which will be different from the old child reference.
To see how to use this API, let's define a function that could transfer a child object to a new parent:
public entry fun transfer_child(parent: &mut Parent, child: Child, new_parent: &mut Parent) {
let child_ref = option::extract(&mut parent.child);
let new_child_ref = transfer::transfer_child_to_object(child, child_ref, new_parent);
option::fill(&mut new_parent.child, new_child_ref);
}
Similar to remove_child
, the child
object must be passed explicitly by-value in the arguments. First of all we extract the existing child reference, and pass it to transfer_child_to_object
along with child
, and a mutable reference to new_parent
. This call will return a new child reference. We then fill the new_parent
's child
field with this new reference. Since ChildRef
type is not droppable, Option::fill
will fail if new_parent.child
already contains an existing ChildRef
. This ensures that we never accidentally drop a ChildRef
without properly transferring the child.
For the same reasons that transferring a child object requires both the child object and the ChildRef
, deleting child objects directly without taking care of the child reference will lead to a stale reference pointing to a non-existing object after the deletion.
There are two ways to delete a child object:
- First transfer this child object to an account address, which makes this object a regular account-owned object instead of a child object, and hence can be deleted normally.
- Use a special API to delete the child object directly along with the child reference.
What happens if we try to delete a child directly using what we learned in the first chapter, without taking the child reference? Let's find out. We can define a simple delete_child
method like this:
public entry fun delete_child(child: Child, _parent: &mut Parent) {
let Child { id } = child;
id::delete(id);
}
If you follow the wallet interaction above and then try to call the delete_child
function here on a child object, you will see the following runtime error:
An object that's owned by another object cannot be deleted or wrapped.
It must be transferred to an account address first before deletion
If we follow the suggestion, fist call remove_child
to turn this child object to an account-owned object, and then call delete_child
again, it will succeed! This is intuitive, but rather inconvenient: it requires two transactions to achieve the effect.
The Transfer
library provides a delete_child_object
API to delete a child object directly. It is much more convenient than transfer + delete as it can be done in one transaction instead of two.
The delete_child_object
API is defined as following:
public fun delete_child_object<T: key>(
child_id: VersionedID,
child_ref: ChildRef<T>,
);
The function takes both the ID of the child object and the child reference as arguments. As explained in chapter 1, to delete an object we must first unpack the object, and upon doing so a non-droppable id
will need to be deleted explicitly.
Instead of calling id::delete
on the id
, for child object, here we require calling transfer::delete_child_object
with the id
and the child reference.
To demonstrate how to use this API, we define a function that can delete a parent object and a child object altogether:
public entry fun delete_parent_and_child(parent: Parent, child: Child) {
let Parent { id: parent_id, child: child_ref_opt } = parent;
let child_ref = option::extract(&mut child_ref_opt);
option::destroy_none(child_ref_opt);
id::delete(parent_id);
let Child { id: child_id } = child;
transfer::delete_child_object(child_id, child_ref);
}
In the above example, after we unpacked the parent
object we are able to extract the child_ref
. We then also unpack the child
object to obtain the child_id
.
Notice that when deleting the parent
object, we called id::delete
, while when deleting the child
object, we called delete_child_object
.