Post
Share your knowledge.
Cross-Module Child Management with public_receive
This is the part 3 of "Parent-Child Objects in Sui Move" series.
Sometimes your parent and child types are defined in different modules or even different packages. For example, you might have a generic Warehouse object that can store any kind of Parcel objects. The Warehouse module wants to pull out a Parcel child, but the Parcel type is defined elsewhere. In such cases, we use transfer::public_receive, which is the cross-module cousin of receive.
receive vs public_receive
As we saw, transfer::receive
transfer::public_receive
Why require store? Because store marks that the object can be safely persisted and passed around outside of its defining module. Key-only objects might have custom invariants that the original module wants to enforce on transfer/receive; by excluding those from public_receive, Sui forces developers to handle them in-module (as we’ll see with soul-bound objects). If an object has store, it’s more permissive, and Sui allows generic transfer/receive logic to manage it externally .
Example: Separate Parent and Child Modules
Let’s illustrate with a simple scenario: a Warehouse that stores Parcel objects. The Parcel type is defined in its own module, and the Warehouse in another. We’ll show how Warehouse can receive a Parcel child using public_receive.
module demo::parcel { // Child module
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
/// A parcel object that can be stored in a Warehouse.
/// It has both key and store, so it can be transferred across modules.
struct Parcel has key, store { id: UID, contents: vector<u8> }
public entry fun create_parcel(contents: vector<u8>, ctx: &mut TxContext): Parcel {
Parcel { id: object::new(ctx), contents }
}
}
module demo::warehouse { // Parent module
use sui::transfer::{Self, Receiving, public_receive};
use demo::parcel::{Self, Parcel};
use sui::object::{UID};
use sui::tx_context::{Self, TxContext};
struct Warehouse has key { id: UID, location: address }
public entry fun create_warehouse(location: address, ctx: &mut TxContext): Warehouse {
Warehouse { id: object::new(ctx), location }
}
/// Receive a Parcel that was sent to this Warehouse.
/// Returns the Parcel to the caller (transferred to caller's address).
public entry fun withdraw_parcel(
warehouse: &mut Warehouse,
parcel_ticket: Receiving<Parcel>,
ctx: &mut TxContext
): Parcel {
// Using public_receive because Parcel is defined in another module and has store
let parcel = public_receive<Parcel>(&mut warehouse.id, parcel_ticket) [oai_citation_attribution:27‡docs.sui.io](https://docs.sui.io/concepts/transfers/transfer-to-object#:~:text=%2F%2F%2F%20Given%20mutable%20,argument) [oai_citation_attribution:28‡github.com](https://github.com/MystenLabs/sui/blob/main/crates/sui-framework/packages/sui-framework/sources/transfer.move#:~:text=public%20fun%20public_receive,T%3E%29%3A%20T);
// Transfer the parcel to the transaction sender (so the caller gets ownership)
transfer::transfer(parcel, tx_context::sender(ctx));
// We return nothing because we've transferred the Parcel out to the caller.
}
}
Let’s break down what’s happening in withdraw_parcel:
- We call public_receive
(&mut warehouse.id, parcel_ticket). Because Parcel has the store ability, this call is allowed even though we’re not in the parcel module . Under the hood, this does the same check and extraction as receive, but it’s permitted cross-module since store indicates it’s safe to do so. https://github.com/MystenLabs/sui/blob/main/crates/sui-framework/packages/sui-framework/sources/transfer.move#:~:text=public%20fun%20public_receive,T%3E%29%3A%20T - We then immediately transfer the received Parcel to the caller’s address (tx_context::sender(ctx)). This step ensures the parcel leaves the warehouse and goes to the user who initiated the withdrawal. We could also have just returned Parcel from the function, and Sui would treat it as an output to be owned by the caller’s address (since it’s an entry function output). Doing an explicit transfer is more verbose, but makes it clear what’s happening (and allows us to do any checks before releasing the object).
Why include store in Parcel? If Parcel lacked the store ability (i.e. was only has key), the public_receive
Function call pattern: To use these in a transaction, the flow would be:
- Deposit (transfer to object): Call transfer::public_transfer(parcel_obj, @warehouse_id) to send a Parcel into a Warehouse. This marks the parcel’s owner as the warehouse. (We use public_transfer here because it’s outside the Parcel module and Parcel has store . Inside the parcel’s module, a plain transfer would work too.)
- Withdraw (receive back): Later, call withdraw_parcel(warehouse_obj, Receiving
(parcel_id, ...)). The Receiving can be obtained by the SDK by referencing the parcel’s ID and latest version. The function will call public_receive and then transfer the parcel to you.
After the withdraw_parcel call, the parcel’s owner is back to an address (yours), so it’s a normal address-owned object again. The warehouse no longer owns it.
Cross-module considerations: Notice that the Warehouse module needed to know about the Parcel type (we use demo::parcel::Parcel). This is because we explicitly type the Receiving as Receiving
Why public_receive instead of just calling receive? If we tried transfer::receive
Don’t forget the parent’s permission: Even with public_receive, you still need that &mut warehouse.id. We got it because withdraw_parcel is in Warehouse’s module and accepts &mut Warehouse. Thus, only someone who could call that (the warehouse’s owner) can withdraw the parcel. If the warehouse module didn’t provide such a function publicly, no one could externally call public_receive on its children either. So cross-module doesn’t bypass the parent’s control; it just allows the parent’s code to work with children of types it didn’t define.
A note on store ability: Giving an object store makes it more flexible but slightly less restricted – any module with the parent reference can pull it out using public_receive. If you want to restrict how an object is retrieved (for example, enforce custom logic or prevent easy extraction), you might deliberately make it key-only. We’ll see an example of that with soul-bound objects. In those cases, you might implement a custom receive function instead of relying on public_receive.
To sum up this part: public_receive is your friend for managing child objects defined in other modules, as long as those objects have the store ability. It allows you to build cross-module systems (like our Warehouse/Parcel) while still respecting ownership and access control. Just remember to include store on child types and use public_transfer when sending them to a parent from outside their module .
- Sui
- Architecture
Sui is a Layer 1 protocol blockchain designed as the first internet-scale programmable blockchain platform.