Sui.

Post

Share your knowledge.

article banner.
harry phan.
Apr 24, 2025
Article

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 can only be called in the module that defines T (or a friend) because it doesn’t require T: store . The Move bytecode verifier actually ensures that in any call to receive, the type T is from the current module . This is a safety restriction for key-only objects.

transfer::public_receive is a variant that requires T: key + store but allows receiving outside T’s module . In other words, if the object type has the store ability (meaning it’s allowed to exist in global storage freely), then any module (given a &mut UID of the parent) can receive it using public_receive . This is perfect for cases where the parent’s module is different from the child’s module.

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 call in demo::warehouse would not compile – Sui enforces that T has store for public_receive . In that case, we’d be forced to retrieve the parcel using receive within the parcel module itself (or use some friend relationship), which complicates cross-module design. By adding store to Parcel, we effectively say “this object can be freely moved and received by external modules,” which is what we want for a generic container pattern.

Function call pattern: To use these in a transaction, the flow would be:

  1. 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.)
  2. 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. If you wanted a truly generic container that could receive any type of object, you’d have to use generics or a different approach (possibly dynamic fields with type erasure). But for most use-cases, you’ll know what types of children you expect.

Why public_receive instead of just calling receive? If we tried transfer::receive(&mut warehouse.id, parcel_ticket) in the warehouse module, the Move verifier would reject it because Parcel is not defined in demo::warehouse. Sui provides the public_receive as the blessed way to do this with an extra ability check (requiring store). Similarly, Sui has transfer vs public_transfer, freeze_object vs public_freeze_object, etc., following the same pattern: the public_ versions are for use outside the defining module and require store .

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
5
Share
Comments
.

Sui is a Layer 1 protocol blockchain designed as the first internet-scale programmable blockchain platform.

258Posts368Answers
Sui.X.Peera.

Earn Your Share of 1000 Sui

Gain Reputation Points & Get Rewards for Helping the Sui Community Grow.

Reward CampaignMay
We use cookies to ensure you get the best experience on our website.
More info